diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77b294f..28a155d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,10 @@ jobs: - run: npm ci - run: npm run build - run: npm test + # React hooks rules only (rules-of-hooks, exhaustive-deps) — see + # .oxlintrc.json. oxlint rather than ESLint: typescript-eslint can't + # parse with TypeScript 7. + - run: npm run lint rust: name: cargo test @@ -66,7 +70,7 @@ jobs: # Pinned to the same toolchain as release.yml so a surprise rustc # release can't make CI and the release build disagree. Bump both # together. - - uses: dtolnay/rust-toolchain@1.88.0 + - uses: dtolnay/rust-toolchain@1.90.0 - uses: Swatinem/rust-cache@v2 with: @@ -82,16 +86,18 @@ jobs: # flag in src/ollama_launch.rs. A mistake in one of those would pass every # PR check and first fail inside release.yml's build matrix, potentially # after another platform's job has already pushed the tag (which then - # forces the delete-tag-and-draft recovery dance). `check` rather than - # `test`: the point is that the platform's code compiles, and the tests - # themselves are platform-independent. + # forces the delete-tag-and-draft recovery dance). The whole suite isn't + # run here — most tests are platform-independent — but the workspace file + # tools' are not: drive letters, reserved names, 8.3 short names and + # junctions only exist on Windows, and their confinement tests never ran + # anywhere else. (The job keeps its name: branch protection matches it.) rust-windows: name: cargo check (windows) runs-on: windows-latest steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@1.88.0 + - uses: dtolnay/rust-toolchain@1.90.0 - uses: Swatinem/rust-cache@v2 with: @@ -100,6 +106,9 @@ jobs: - run: cargo check --locked --all-targets working-directory: src-tauri + - run: cargo test --locked --lib tools::fs + working-directory: src-tauri + # macOS-only compile check. Until this existed, the macOS target was built # for the FIRST time inside release.yml's matrix — so a mistake in a # `#[cfg(target_os = "macos")]` arm (the `open -a` launch path in @@ -120,7 +129,7 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@1.88.0 + - uses: dtolnay/rust-toolchain@1.90.0 - uses: Swatinem/rust-cache@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5945e49..ffd713c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: should_release: ${{ steps.gate.outputs.should_release }} release_body: ${{ steps.notes.outputs.body }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # need full history to inspect existing tags @@ -100,6 +100,19 @@ jobs: echo 'RELEASE_NOTES_EOF' } >> "$GITHUB_OUTPUT" + # Push the tag before anything builds. The gate above keys on it, and + # nothing else creates it in time: tauri-action only drafts the release, + # and GitHub creates a draft's tag when the draft is published. Without + # this, a push to main between the draft and Publish passed the gate + # again and rebuilt the draft's assets from the newer commit. + - name: Tag the release commit + if: steps.gate.outputs.should_release == 'true' + run: | + set -euo pipefail + TAG="v${{ steps.gate.outputs.version }}" + git tag "$TAG" "$GITHUB_SHA" + git push origin "refs/tags/$TAG" + build: needs: prepare if: needs.prepare.outputs.should_release == 'true' @@ -145,11 +158,15 @@ jobs: git curl ca-certificates xz-utils sudo git config --global --add safe.directory '*' - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Tauri-action needs full history to compute the right tag base # for the auto-created tag. Matches `prepare`'s checkout. fetch-depth: 0 + # Nothing in this job pushes (tauri-action talks to the API with + # GITHUB_TOKEN), so don't leave the token in .git/config for + # `npm ci` install scripts and build scripts to find. + persist-credentials: false # Runs as root inside the container, so no sudo. build-essential, # pkg-config and libssl-dev come preinstalled on the runner image but @@ -173,7 +190,7 @@ jobs: wget \ desktop-file-utils - - uses: actions/setup-node@v7 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 cache: npm @@ -185,18 +202,120 @@ jobs: # opaque failure that costs hours to diagnose. Bump this manually # when intentionally adopting a new toolchain. # - # 1.88.0 is the floor imposed by current transitive deps: `toml 0.9` / - # `serde_spanned 1.1` need edition2024 (1.85+), `icu_*` need 1.86+, - # and `darling`, `image`, `serde_with`, `time` need 1.88+. - - uses: dtolnay/rust-toolchain@1.88.0 + # 1.90.0 is the floor imposed by current deps: `tauri` 2.12 and its + # plugins, `muda` and `tray-icon` need 1.90+; before them `darling`, + # `image`, `serde_with`, `time` set it at 1.88. + # + # Every action is pinned to a commit, not a tag: a moved tag would run + # new code in a job that holds the updater signing key. + - uses: dtolnay/rust-toolchain@7dcc700092a1a8b9c17ba0e140b4e34e93000946 # 1.90.0 + with: + toolchain: 1.90.0 - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: workspaces: src-tauri + # `tauri build` runs cargo without --locked, so a Cargo.toml change + # merged without its Cargo.lock refresh would ship whatever versions + # resolve today. Fail first instead, as CI's `cargo test --locked` does. + - name: Check Cargo.lock is current + working-directory: src-tauri + shell: bash + run: cargo metadata --locked --format-version 1 > /dev/null + - run: npm ci - - uses: tauri-apps/tauri-action@v0 + # The frontend is built here, apart from `tauri build`, which would run + # it as its beforeBuildCommand with the updater signing key in the + # environment — where vite, its plugins and every other dev dependency + # could read it. The tauri-action step skips that hook (see its + # `--config`). TAURI_ENV_PLATFORM is the one variable the CLI would + # have set that vite.config.ts reads (it picks the build target). + - name: Build the frontend + run: npm run build + env: + TAURI_ENV_PLATFORM: ${{ matrix.platform == 'windows-latest' && 'windows' || (matrix.platform == 'macos-14' && 'darwin' || 'linux') }} + + # Signs the macOS app with a stable self-signed identity instead of the + # ad-hoc one in tauri.conf.json, once the two MACOS_SIGNING_* secrets + # exist. Gatekeeper treats both alike (neither is notarized); the + # Keychain doesn't. It ties Loach's saved items (API key, app-lock hash) + # to the signature's designated requirement, and an ad-hoc requirement + # is the binary's own hash — so after every update macOS took Loach for + # a different app and asked for the login password before it could read + # its own secrets. A certificate's requirement stays the same across + # builds. (Installs updating from an ad-hoc build are asked once more.) + # + # Not tauri's own APPLE_CERTIFICATE import: that only looks for + # Apple-issued certificates ("Developer ID Application:" …) and fails on + # a self-signed one. Exporting APPLE_SIGNING_IDENTITY instead makes the + # bundler run plain `codesign -s ` against the keychain search + # list, skipping notarization as it does now. Any problem here falls + # back to the ad-hoc signature with a warning rather than failing the + # leg — by then another leg may already have pushed the tag. + # + # One-time setup, with OpenSSL (in Git Bash, prefix each command with + # MSYS_NO_PATHCONV=1 so `-subj` isn't rewritten into a Windows path): + # openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \ + # -keyout loach-signing.key -out loach-signing.crt \ + # -subj "/CN=Loach Code Signing" \ + # -addext "keyUsage=critical,digitalSignature" \ + # -addext "extendedKeyUsage=critical,codeSigning" + # openssl pkcs12 -export -inkey loach-signing.key -in loach-signing.crt \ + # -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 \ + # -out loach-signing.p12 + # (SHA1/3DES because macOS `security import` rejects OpenSSL 3's default + # PKCS#12 encryption.) Store `openssl base64 -A -in loach-signing.p12` as + # MACOS_SIGNING_CERTIFICATE and the export password as + # MACOS_SIGNING_CERTIFICATE_PASSWORD. Keep the .p12 as private as the + # updater key — whoever holds it can sign an app the Keychain takes for + # Loach — and keep it: a replacement costs every install another prompt. + - name: Import macOS signing identity + if: matrix.platform == 'macos-14' + env: + MACOS_SIGNING_CERTIFICATE: ${{ secrets.MACOS_SIGNING_CERTIFICATE }} + MACOS_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_SIGNING_CERTIFICATE_PASSWORD }} + run: | + # The default shell runs with -e; every failure below is handled + # explicitly so it can fall back to ad-hoc instead. + set +e -uo pipefail + adhoc() { + echo "::warning::$1 — this build keeps the ad-hoc signature." + exit 0 + } + if [ -z "$MACOS_SIGNING_CERTIFICATE" ]; then + echo "::notice::MACOS_SIGNING_CERTIFICATE not set — keeping the ad-hoc signature." + exit 0 + fi + KC="$RUNNER_TEMP/loach-signing.keychain-db" + KC_PW=$(openssl rand -hex 16) + P12="$RUNNER_TEMP/loach-signing.p12" + trap 'rm -f "$P12"' EXIT + printf '%s' "$MACOS_SIGNING_CERTIFICATE" | base64 --decode > "$P12" \ + || adhoc "MACOS_SIGNING_CERTIFICATE isn't valid base64" + security create-keychain -p "$KC_PW" "$KC" || adhoc "couldn't create a keychain" + # No auto-lock: the release build can outlast any timeout. + security set-keychain-settings "$KC" + security unlock-keychain -p "$KC_PW" "$KC" + security import "$P12" -k "$KC" -P "$MACOS_SIGNING_CERTIFICATE_PASSWORD" -T /usr/bin/codesign \ + || adhoc "couldn't import the certificate (wrong password, or not a SHA1/3DES .p12?)" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PW" "$KC" >/dev/null \ + || adhoc "couldn't let codesign use the key" + security list-keychains -d user -s "$KC" $(security list-keychains -d user | tr -d '"') + # Self-signed, so it's listed as untrusted, which codesign doesn't + # mind. The SHA-1 names it unambiguously. + ID=$(security find-identity -p codesigning "$KC" | grep -m1 -oE '[0-9A-F]{40}') \ + || adhoc "no code-signing identity in MACOS_SIGNING_CERTIFICATE" + # Sign a scratch binary the way the bundler will before relying on it. + cp /usr/bin/true "$RUNNER_TEMP/probe" + codesign --force -s "$ID" --options runtime "$RUNNER_TEMP/probe" \ + && codesign --verify "$RUNNER_TEMP/probe" \ + || adhoc "codesign refused the identity" + codesign -d -r- "$RUNNER_TEMP/probe" + echo "APPLE_SIGNING_IDENTITY=$ID" >> "$GITHUB_ENV" + + - uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -205,9 +324,9 @@ jobs: # provide; this makes it self-extract instead. Harmless elsewhere. APPIMAGE_EXTRACT_AND_RUN: 1 with: - # tauri-action will create + push this tag at the commit being built - # if it doesn't already exist. First matrix job to finish creates - # the release; subsequent jobs upload their assets to the same one. + # The `prepare` job already pushed this tag at the commit being + # built. First matrix job to finish creates the release; subsequent + # jobs upload their assets to the same one. tagName: v${{ needs.prepare.outputs.version }} releaseName: 'Loach v${{ needs.prepare.outputs.version }}' releaseBody: ${{ needs.prepare.outputs.release_body }} @@ -215,5 +334,77 @@ jobs: # Anything with a pre-release qualifier (e.g. `-alpha`, `-beta`, # `-rc.1`) is flagged prerelease; bare `1.2.3` ships as stable. prerelease: ${{ contains(needs.prepare.outputs.version, '-') }} - includeUpdaterJson: true - args: ${{ matrix.args }} + # Since v1 the `url`s in latest.json are API asset links + # (api.github.com/.../releases/assets/), stable across the + # draft -> publish switch. Every shipped install (updater plugin + # 2.10.1+) downloads them fine: it sends Accept: + # application/octet-stream and follows the redirect to the file. + uploadUpdaterJson: true + # prebuilt-frontend.conf.json empties beforeBuildCommand: the + # frontend was built in its own step, without the signing key. + # (A file, not inline JSON, so no shell or argument splitting can + # mangle its quotes. TAURI_CONFIG doesn't reach the build hooks.) + # Cargo's build scripts still see the key — the bundler signs in + # this same `tauri build`. + args: ${{ matrix.args }} --config src-tauri/prebuilt-frontend.conf.json + + # tauri-action merges each leg's entries into latest.json by downloading, + # editing and re-uploading it, so two legs finishing together can drop one + # platform's entries. Check the merged file once every leg is done: a gap + # fails here, loudly, instead of reaching installs as an update that never + # arrives. (The fix is re-running the build leg for the missing platform.) + verify-updater-json: + needs: [prepare, build] + runs-on: ubuntu-24.04 + steps: + - name: Check latest.json lists every platform + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + TAG="v$VERSION" + # Drafts aren't reachable by tag, so find it in the release list. + ASSET_ID=$(gh api "repos/$GITHUB_REPOSITORY/releases" --paginate \ + --jq ".[] | select(.tag_name == \"$TAG\") | .assets[] | select(.name == \"latest.json\") | .id" | head -n1) + if [ -z "$ASSET_ID" ]; then + echo "::error::No latest.json on the $TAG release" + exit 1 + fi + gh api -H "Accept: application/octet-stream" \ + "repos/$GITHUB_REPOSITORY/releases/assets/$ASSET_ID" > latest.json + + ANNOUNCED=$(jq -r '.version' latest.json) + if [ "${ANNOUNCED#v}" != "$VERSION" ]; then + echo "::error::latest.json announces $ANNOUNCED, not $VERSION" + exit 1 + fi + problems=() + # The keys CLAUDE.md's release checklist expects (v1.2.3's set). + for key in windows-x86_64 windows-x86_64-nsis \ + linux-x86_64 linux-x86_64-appimage linux-x86_64-deb linux-x86_64-rpm \ + darwin-aarch64 darwin-aarch64-app; do + sig=$(jq -r --arg k "$key" '.platforms[$k].signature // ""' latest.json) + url=$(jq -r --arg k "$key" '.platforms[$k].url // ""' latest.json) + if [ -z "$sig" ] || [ -z "$url" ]; then + problems+=("$key: missing") + continue + fi + # The signed version must match the announced one — updater 2.13+ + # rejects a mismatch (SignedVersionMismatch). + comment=$(printf '%s' "$sig" | base64 -d | grep -m1 '^trusted comment:' || true) + case "$comment" in + *"version:$VERSION"*) ;; + *) problems+=("$key: signed for another version ($comment)") ;; + esac + # Installs on updater < 2.10 read only the plain key, and must + # get the AppImage, never a .deb/.rpm. + if [ "$key" = linux-x86_64 ] && ! printf '%s' "$comment" | grep -q 'file:[^[:space:]]*\.AppImage'; then + problems+=("linux-x86_64: not the AppImage ($comment)") + fi + done + if [ ${#problems[@]} -gt 0 ]; then + printf '::error::%s\n' "${problems[@]}" + exit 1 + fi + echo "latest.json lists every platform, signed for $VERSION" diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..8a99649 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,18 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react"], + "categories": { + "correctness": "off", + "suspicious": "off", + "pedantic": "off", + "perf": "off", + "style": "off", + "restriction": "off", + "nursery": "off" + }, + "rules": { + "react/rules-of-hooks": "error", + "react/exhaustive-deps": "error" + }, + "ignorePatterns": ["dist/**", "src-tauri/**"] +} diff --git a/Dockerfile b/Dockerfile index f0d5997..248176e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,13 +47,13 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* -# Pinned to the toolchain CI uses (dtolnay/rust-toolchain@1.88.0) rather than +# Pinned to the toolchain CI uses (dtolnay/rust-toolchain 1.90.0) rather than # `stable`, for the same reason CI pins it: a surprise rustc release shouldn't # be able to break a build that worked yesterday. Minimal profile — the build # image has no use for rustfmt/clippy. Keep in step with `rust-version` in # src-tauri/Cargo.toml. RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --default-toolchain 1.88.0 --profile minimal + | sh -s -- -y --default-toolchain 1.90.0 --profile minimal WORKDIR /src diff --git a/README.md b/README.md index 99971e1..4f99b98 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Run local LLMs with [Ollama](https://ollama.com) or connect any OpenAI-compatibl ![Tauri 2](https://img.shields.io/badge/Tauri-2-FFC131?logo=tauri&logoColor=white) ![React 19](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=white) -![Rust 1.88+](https://img.shields.io/badge/Rust-1.88+-000000?logo=rust&logoColor=white) +![Rust 1.90+](https://img.shields.io/badge/Rust-1.90+-000000?logo=rust&logoColor=white) ![Vite](https://img.shields.io/badge/Vite-8-646CFF?logo=vite&logoColor=white) ![Tailwind CSS](https://img.shields.io/badge/Tailwind-3-38BDF8?logo=tailwindcss&logoColor=white) ![Ollama](https://img.shields.io/badge/Ollama-compatible-000000?logo=ollama&logoColor=white) @@ -30,7 +30,7 @@ With local models, including Qwen, Gemma, DeepSeek, GPT-OSS and Mistral, all you Behind a calm, beautifully crafted UI sits a rich feature set - ready when you need it, out of the way when you don't. -![Loach UI](docs/images/loach-ui.png) +![Loach UI: a new chat explains a concept, calls the calculator tool, writes TypeScript and summarizes a PDF](docs/images/loach-demo.webp) --- @@ -47,6 +47,7 @@ Behind a calm, beautifully crafted UI sits a rich feature set - ready when you n #### Organizing chats & content - **Spaces** - group chats around a project, with shared instructions, reference sources and memory. +- **Library** - keep texts, files (PDF, Word, code, images) and web links in one place, group them into datasets, and attach them to any chat (`+` → From Library, or `#`) or Space. Small sources go to the model in full; big ones are searched, and only the most relevant passages are sent - by keyword out of the box, and by meaning with an optional embedding model. Each reply shows which sources it used. - **Global memories** - opt-in (**Settings → Features**): after each reply outside a Space, Loach notes lasting facts about you and updates or retires ones that changed, with an Undo on every change; the facts go with you into every chat, Spaces included. Space memory works the same way, and both have a searchable editor that links each fact back to the chat it came from. - **Chat folders** - drag one chat onto another in the sidebar to group them into a named folder. - **Chat labels** - tag a chat with a colour from any chat menu; it shows as a dot next to the chat in the sidebar and Space view. @@ -56,7 +57,7 @@ Behind a calm, beautifully crafted UI sits a rich feature set - ready when you n - **Pinned responses** - pin any reply and jump straight back to it from a bar under the chat header. - **Private chats** - an ephemeral chat that writes nothing to disk and wipes its transcript the moment you close it; open it from the ghost icon in the title bar. - **Chat archive** - move chats out of the sidebar without deleting them, with an inline Undo if you mis-clicked; restore or delete them from dedicated archive view. -- **Search** - search across chats, message content, spaces and snippets, narrowed with a scope picker or an `in:` filter, plus a browser-style in-chat finder with phrase highlighting. +- **Search** - search across chats, message content, spaces, snippets and the Library, narrowed with a scope picker or an `in:` filter (`in:library` for Library items and datasets), plus a browser-style in-chat finder with phrase highlighting. #### Composing & steering a chat - **Slash commands** - type `/` in the composer for a command palette: `/fork`, `/regenerate`, `/compact`, `/private`, `/model`, `/persona`, `/snippet`, `/remember` and more. @@ -74,19 +75,19 @@ Behind a calm, beautifully crafted UI sits a rich feature set - ready when you n - **Temporal awareness** - inject current date, weekday and timezone into the system prompt so models can answer to "what day is it today?". #### Viewing content -- **Code canvas** - open any code block in a wider view that's resizable and updates live as the model streams, with copy, export and Open in VS Code actions. +- **Code canvas** - open any code block in a wider view that's resizable and updates live as the model streams, with copy, export and Open in VS Code actions. HTML renders as a live page in an offline sandbox. - **Attachment previews** - click an attachment to open the right viewer: image lightbox, multi-page PDF preview, code canvas, or a file-info card with Save. - **Share a message** - copy any message as text or as a rendered chat-bubble image, save the image as a PNG, or open a pre-filled post on Facebook, X, Reddit or LinkedIn. #### App, data & updates -- **Data management** - make backups of your content to JSON file (MCP headers, environment variables and chats' workspace folders are left out), restore data or permanently delete it with a few clicks, with a storage breakdown showing what your chats, attachments and Spaces actually weigh on disk. +- **Data management** - make backups of your content to JSON file (MCP headers, environment variables and chats' workspace folders are left out), restore data or permanently delete it with a few clicks, with a storage breakdown showing what your chats, attachments, Spaces and Library actually weigh on disk. - **App lock** - optional PIN, password or PIN + password gate at launch, with opt-in auto-lock after inactivity or on minimize and a lock-now shortcut; credentials are hashed and stored in OS credential manager. - **Themes** - glassy, gradient Aurora or flat Solid, both available in Dark and Light variants. - **OTA updates** - get new features, bug fixes, performance improvements and security patches directly from the app, with an opt-in check at launch that tells you when a new version is out. ...and more! -We are working on extending the list above, including new RAG and agentic features. +We are working on extending the list above, including new agentic features. --- @@ -136,8 +137,8 @@ Auto-updates are delivered through Loach's own signed updater (independent of Ap #### Prerequisites -- **Node.js 20.19+** (or 22.12+) and **npm** — Vite 8 won't run on older 20.x point releases. -- **Rust 1.88+** via [`rustup`](https://rustup.rs) — the dependency tree's minimum; CI builds on 1.88.0 +- **Node.js 22.13+** (or 24 / 26+) and **npm** — the locked pdfjs-dist 6 needs 22.13, and Vitest 5 runs only on Node 22, 24 and 26+. +- **Rust 1.90+** via [`rustup`](https://rustup.rs) — the dependency tree's minimum; CI builds on 1.90.0 - Platform build tooling — install once via the official Tauri prerequisites guide: - **Windows**: Microsoft Visual Studio Build Tools, WebView2 runtime (preinstalled on Windows 11) - **Linux**: `libwebkit2gtk-4.1-dev`, `libappindicator3-dev` (or `libayatana-appindicator3-dev`), `librsvg2-dev`, `build-essential`, `libssl-dev`, `pkg-config`; bundling `.AppImage` / `.rpm` installers also needs `patchelf`, `rpm`, `file`, `wget` and `desktop-file-utils`. No Secret Service headers are needed — the keyring backend is pure Rust. @@ -215,13 +216,14 @@ Only one OpenAI-compatible endpoint is active at a time — switch the base URL | Icons | `lucide-react` | | State | Zustand (in-memory; backed by SQLite for persistence where appropriate) | | Storage | SQLite via `rusqlite` (bundled feature — no system dependency) | +| Library search | SQLite FTS5 keyword index + optional embeddings (Ollama or OpenAI-compatible), merged by reciprocal-rank fusion | | Secrets | `keyring` crate → Windows Credential Manager / Linux Secret Service / macOS Keychain | -| Argon2id | `argon2` + `rand_core` (for app-lock hashing) | +| Argon2id | `argon2` (for app-lock hashing) | | HTTP | `reqwest` with streaming + `rustls-tls` | | Markdown | `react-markdown` + `remark-gfm` + `rehype-highlight` (highlight.js) | | Math | `remark-math` + `rehype-katex` + KaTeX, lazy-loaded from the bundle on first use | | Document parsing | `pdfjs-dist` (PDF) + `mammoth` (DOCX) | -| PDF generation | `printpdf` 0.12 with bundled Liberation Sans fonts, subset at render time (Unicode-capable output) | +| PDF generation | `printpdf` 0.12 with bundled Liberation Sans fonts, subset at render time (Latin-script text; other characters print as `?`) | | Workspace files | `walkdir` + `glob` + `regex` (no backtracking), `encoding_rs` + `chardetng` to edit non-UTF-8 files in their own encoding | | System tray | Tauri 2 built-in (`tray-icon` feature) | | Bundle targets | `.exe` (NSIS) on Windows; `.deb` / `.rpm` / `.AppImage` on Linux; `.dmg` + `.app` on macOS (Apple Silicon) | @@ -232,15 +234,16 @@ Only one OpenAI-compatible endpoint is active at a time — switch the base URL | What | Where | |---|---| -| Chats, messages, folders, spaces (instructions, reference sources, memories), global memories, snippets, snippet variables and saved fill-ins, MCP servers (including their headers and environment variables, in plain text), app settings | SQLite at `/loach.db` | +| Chats, messages, folders, spaces (instructions, reference sources, memories), the Library (texts, files, links, datasets and their search index), global memories, snippets, snippet variables and saved fill-ins, MCP servers (including their headers and environment variables, in plain text), app settings | SQLite at `/loach.db` | | OpenAI API key | OS credential manager (Windows Credential Manager / Linux Secret Service / macOS Keychain) | | App-lock hash + hint | OS credential manager — same store, separate entry | +| App-lock marker (whether a lock is set, its method, PIN length and whether it has a hint — no hashes) and failed-unlock count | `app_lock.json` and `app_lock_attempts.json` next to `loach.db` | | Attached files (images, text, PDF, DOCX) and PDFs the pdf tool generates | Stored with their message in `loach.db`; no separate file store | | App data dir on Windows | `%APPDATA%\dev.loach.app\` | | App data dir on Linux | `~/.local/share/dev.loach.app/` | | App data dir on macOS | `~/Library/Application Support/dev.loach.app/` | -Loach launches and works completely offline as long as you stick to local providers. The **Models** library, **Spaces**, **Snippets**, search, parameter sidebar, app lock, and chat history are all available without network access. Only chat generations against remote endpoints (OpenAI, Groq, OpenRouter, …), model pulls, web fetch, remote MCP servers (and the first launch of an `npx`/`uvx` one, which downloads its package) and update checks require internet connection. +Loach launches and works completely offline as long as you stick to local providers. The **Models** library, **Spaces**, the **Library**, **Snippets**, search, parameter sidebar, app lock, and chat history are all available without network access. Only chat generations against remote endpoints (OpenAI, Groq, OpenRouter, …), model pulls, web fetch and Library links, embeddings from a remote endpoint, remote MCP servers (and the first launch of an `npx`/`uvx` one, which downloads its package) and update checks require internet connection. --- diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 148b512..d41ec2e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -68,12 +68,21 @@ plus vLLM, LM Studio, LiteLLM, OpenRouter, Groq, and other proxies. count (or a readable error) so the user can verify a base URL + key pair before opening a chat. Disabled while there's an unsaved key in the input. +- **Reasoning models** — OpenAI's o-series and GPT-5 refuse `max_tokens` + and a non-default `temperature`. When a server turns a request down over + either field, Loach resends it with `max_completion_tokens` in place of + `max_tokens`, or without the temperature. +- **Context window** — these servers don't report their window and ignore + `num_ctx`, so Loach assumes **32k** (32,768 tokens) unless the chat sets + a context length (§8). The figure only sizes Library sources (§4.4) and + the context usage bar (§2.10). ### 1.3 Model picker Every chat shows a model dropdown in its header that lists both providers' -catalogs grouped by provider (**Ollama** and **API**; the API group shows up -to 30 models). Switching a chat's model: +catalogs grouped by provider (**Ollama** and **API**; the API group lists +every model the endpoint offers — the menu scrolls, and typing a model's +first letters jumps to it). Switching a chat's model: - Persists onto the session row so reloads remember it. - Updates the global "most recent (provider, model)" pair used by **Default @@ -106,7 +115,7 @@ transcript of messages. way completes on disk regardless). An MCP call that needs your approval shows an **approval card** above the block — server, tool, arguments, and **Allow once**, **Always allow - ** and **Deny** — and the reply waits until you answer (§8.2). + ** and **Deny** — and the reply waits until you answer (§9.2). - **Metrics chip** — every finished assistant turn shows the completion token count, wall-clock time, and tokens/sec. Ollama reports the count itself; OpenAI-compatible endpoints do so only when they send a `usage` @@ -154,8 +163,9 @@ transcript of messages. **Save as Snippet** on user prompts, and **Regenerate** on the *last* assistant message (drops the previous reply, re-sends the preceding user turn, and streams a fresh answer in place). **Share** (§2.12) is on every - bubble; **Pin this response** (§2.11) and **Fork from here** (§2.6) on - assistant replies. + bubble; **Pin this response** (§2.11), **Fork from here** (§2.6) and + **Save to Library** (§4) on assistant replies, and **Save attachments to + Library** on user prompts that carry files. ### 2.2 Composer @@ -166,8 +176,10 @@ The input box at the bottom of every chat. command palette and run chat actions, switch model, fetch a URL, and more without leaving the keyboard (see §2.9). - **`+` menu** — **Add files** opens the file dialog (drag and drop also - works anywhere in the window); **Add directory** gives the chat a - workspace folder, and reads **Change directory** once it has one (§8.4). + works anywhere in the window); **From Library** links saved texts, files, + links and datasets to the chat — or type `#` in the message box (§4.3); + **Add directory** gives the chat a workspace folder, and reads **Change + directory** once it has one (§9.4). - **Suggestion chips** — on the welcome hero screen of an empty chat, shortcuts seed the composer with starter prompts ("Explain a concept", "Write code", "Summarize a file", "Brainstorm"). @@ -183,6 +195,10 @@ The input box at the bottom of every chat. - **Send / Stop morph** — while a reply is streaming for the active chat, the send button becomes a stop button. Cancelling persists whatever has been streamed so far. +- **Drafts per chat** — unsent text and attached files stay with the chat + (or the Space page) they were written in, so switching chats never + carries them along. Drafts are kept in memory only; quitting Loach drops + them. ### 2.3 Attachments @@ -192,7 +208,8 @@ Drop any file up to **20 MB** into the composer. Loach handles three flavours: models alongside the text turn. - **Text and code** (one of ~70 recognised extensions, plus anything with a `text/*` MIME) — read verbatim and inlined into the prompt as a fenced - code block. + code block. UTF-16 files and legacy Windows code pages (Windows-1250 / + -1252, picked by the UI language) are decoded, not just UTF-8. - **Documents** — **PDF** (text-extracted via `pdfjs-dist`) and **DOCX** (extracted via `mammoth`). @@ -217,7 +234,7 @@ chat: closes it, matching standard lightbox behaviour). - **PDFs** with original bytes retained → multi-page rendered preview (lazy per-page rasterisation via `pdfjs-dist`) with **Save**. -- **Text and code** → opens in the **Code canvas** (§10) with language +- **Text and code** → opens in the **Code canvas** (§11) with language highlighting and export (in Private Chat, the file-info card below). - **DOCX, binaries, and older PDFs without raw bytes** → a file-info card with **Save**, so the user can still pull the attachment back out even @@ -234,7 +251,7 @@ FIFO queue: - A waiting chat shows a **Waiting for other chats to finish…** banner in its transcript with **Respond now** to jump the queue and cancel the current runner (and **Cancel** to withdraw the request). -- A reply waiting on a tool approval card (§8.2, §8.4) keeps its turn, so +- A reply waiting on a tool approval card (§9.2, §9.4) keeps its turn, so other chats queue behind it until you answer the card — or use **Respond now** in a waiting chat, which cancels the waiting reply. - A cancelled stream keeps whatever streamed before you stopped it; an @@ -282,12 +299,16 @@ pending restore. - **Search in chat**, **Pin this chat** / **Unpin**, **Label**, **Rename**, **Move to archive** / **Restore from archive**, **Delete**. - **Fork this chat** — clone the conversation into a new chat (same model, - Space, sidebar folder, workspace folder, instructions and parameters) so + Space, sidebar folder, workspace folder, Library sources, instructions and + parameters) so you can branch a tangent without disturbing the original. The fork carries a **"Forked from …"** badge in its header that jumps back to the source; the badge disappears if the source is deleted. Also available as **Fork from here** in an assistant reply's `…` menu (forks - up to and including that reply) and via the `/fork` command. + up to and including that reply) and via the `/fork` command. Forking + waits for a running reply to finish. A fork cut before the last turn a + compaction (§2.10) summarised leaves the summary behind and sends the + turns it copied in full. - **Export context** — opens a dialog with the transcript rendered as Markdown and a **Copy** button. A **Compact context** toggle in the dialog shows a summary written by the chat's model instead of the @@ -334,16 +355,22 @@ no trace. Open it from the ghost icon in the title bar. - **Nothing is persisted.** No session row, no message row, no metrics, no attachment store. The transcript lives entirely in memory and is wiped the moment the overlay closes (along with the picked model, persona, - tone, per-chat instructions, and parameters panel state). + tone, per-chat instructions, and parameters panel state). Locking Loach + (§13) ends the session the same way. - **Ollama-only.** OpenAI-compatible providers aren't shown in the model picker — the data path for the cloud providers crosses too many - intermediaries to honour the "leaves no trace" promise. -- **MCP tools are blocked.** Servers configured in **Settings → MCP** are - not exposed to the model inside Private Chat, so a tool call can't - side-channel the conversation out to a third party. Built-in tools - (§8.3) still work — they run entirely on your machine, so they can't - leak the conversation. The workspace file tools (§8.4) are the - exception: a Private Chat never gets a folder, so it can't write to disk. + intermediaries to honour the "leaves no trace" promise. Ollama's cloud + models (`…:cloud` tags) are left out too, since Ollama runs them on + ollama.com. When the Ollama base URL points at another machine, the + overlay says so: messages go to the server at that address. +- **No tools.** Servers configured in **Settings → MCP** are not exposed + to the model inside Private Chat, so a tool call can't side-channel the + conversation out to a third party — and neither are the built-in tools + (§9.3): the overlay has nowhere to show a call, its result or a PDF one + makes. A Private Chat never gets a workspace folder (§9.4) either. +- **No canvas.** Code blocks keep **Copy** and **Export**, but can't open + the code canvas (§11): it belongs to the regular window and would stay + on screen after the overlay closes. - **No backdrop / Esc close.** The overlay can only be dismissed by the explicit `X` in its header. The wipe is destructive; we don't want a stray click to throw away the conversation. @@ -354,8 +381,8 @@ no trace. Open it from the ghost icon in the title bar. the default tone from **Settings → General** when not overridden), and a free-form per-chat instructions textarea, layered persona → instructions → tone. No **Custom instructions** from **Settings → General**, no Space - context, no `{{USER_NAME}}` substitution, no temporal preamble — those - layers belong to persistent chats. + context or Library sources, no `{{USER_NAME}}` substitution, no temporal + preamble — those layers belong to persistent chats. - **Parameters panel** — the Simple view of the regular panel (context length, model defaults, Thinking and Low VRAM toggles); there is no Advanced view here. Closes and wipes along with the rest of the overlay. @@ -367,29 +394,46 @@ palette** — a floating list that filters as you type. Arrow keys move the selection, **Tab** completes the highlighted command, and **Enter** runs a fully-typed command (or completes a partial one). **Esc** dismisses the palette. Anything that isn't a recognised command is sent as an ordinary -message, so a prompt that happens to start with `/` is never swallowed. +message, so a prompt that happens to start with `/` is never swallowed — +and text that goes on with a space or a second slash (`// note`, +`/ aside`) doesn't open the palette at all. A command that takes no +argument refuses one (`/regenerate but shorter` errors rather than +dropping "but shorter"), and a command that fails leaves its text in the +composer to fix and run again. Commands are grouped the way `/help` lists them: -- **Chat** — `/new`, `/clear`, `/rename `, `/pin`, `/archive`, - `/delete`, `/fork`, `/regenerate`, `/copy [N]` (copy the last or - Nth-latest assistant reply), `/export`, `/stats`, `/compact`, - `/private`. -- **Model & persona** — `/model <name>`, `/persona <name>` (both matched - by full or partial name; several matches show a list to pick from). +- **Chat** — `/new`, `/clear`, `/rename <title>`, `/pin`, + `/label <color|clear>`, `/folder <name|none>` (an existing folder by its + exact name, else a new one), `/archive`, `/delete`, `/fork`, + `/regenerate`, `/stop` (the reply running or queued in this chat, or a + send still being prepared), `/copy [N]` (copy the last or Nth-latest + assistant reply), `/find [text]` (open the in-chat finder), `/export`, + `/import`, `/stats`, `/compact`, `/private`. +- **Model & persona** — `/model [name]`, `/persona [name]`, + `/tone [name]` (matched by full or partial name; several matches show a + list to pick from). Bare `/model` opens the model picker; bare + `/persona` and `/tone` say which one is in effect. Where Ollama and the + OpenAI-compatible endpoint serve the same model id, `/model ollama/<id>` + or `/model openai/<id>` picks one. `/set <param> <value>` sets a + generation parameter (§8) within the panel's own ranges, `/set reset` + drops the chat's overrides, and `/unload` unloads the chat's Ollama + model from memory. - **Listings** — `/list models | personas | spaces | snippets | mcp | providers | memories`. -- **Prompts** — `/instructions <text|clear>` (set or clear the per-chat +- **Prompts** — `/instructions [text|clear]` (set or clear the per-chat system prompt; on its own it shows the current one), `/snippet <name>` (expand a saved snippet into the composer). -- **Memory & spaces** — `/remember <fact>`, `/forget <id|query>`, - `/space <name>`. Inside a Space the memory commands act on that Space's +- **Memory & Spaces** — `/remember <fact>`, `/forget <id|query>`, + `/space <name>` (typed on a Space's page, it opens the other Space's + page). Inside a Space the memory commands act on that Space's memory; outside one they act on the global list. `/remember` needs that memory turned on — the Space's memory toggle, or Global memories. - **Tools & web** — `/tools` (list tools from enabled MCP servers), `/web-fetch on|off`, `/fetch <url>`, `/thinking on|off`. - **App** — `/settings [tab]` (general, providers, features, tools, - appearance, mcp, archive, data, security, updates, about), `/help`. + library, appearance, mcp, archive, data, security, updates, about), + `/theme light|dark|system`, `/lock` (needs an app lock — §13), `/help`. Above those sits a **Recent** group: the four commands you last ran, hoisted out of their normal groups. The list is persisted (six names, in the settings @@ -397,25 +441,31 @@ table) rather than session-scoped, so it's useful on the first `/` after a restart instead of only once you've already run the command that launch. `/clear` and `/delete` are destructive and route through a confirmation -dialog before they run. +dialog before they run. `/clear` also drops the chat's compaction summary +(§2.10) — your own instructions stay — and is refused while the chat is +being compacted. ### 2.10 Context usage and compaction A slim **context usage bar** sits just below the message box once a chat has messages. It shows how much of the model's context window the chat is using — `used / total` tokens and a percentage — with a popover that breaks -the estimate down into the system prompt, project instructions -(`LOACHFILE.md`), memories and messages (attachments called out), plus -what's left of the window. It's an estimate (about four characters per -token), and a Space's instructions and reference sources aren't counted. -The popover's **Expand context window** button doubles this chat's -context length. +the estimate down into the system prompt (a Space's instructions +included), project instructions (`LOACHFILE.md`), memories, sources (what +the next message will send of them, in full or as excerpts — §4.4) and +messages (attachments called out), plus what's left of the window. It's an +estimate, at about four characters per token (about one per character for +Chinese, Japanese and Korean). +On an Ollama chat the popover's **Expand context window** button doubles +this chat's context length. An OpenAI-compatible server ignores that +setting, so there the bar measures against the 32k Loach assumes (§1.2), +or the context length the chat sets, and the button isn't offered. When a chat grows long, **Compact context** (the button in the bar's popover, or the `/compact` command) summarises the older turns with the chat's own model and tucks the summary into the system prompt. The summary doesn't count as the chat's own instructions: it rides along with whichever ones -apply — the Space's, the chat's, or your Custom instructions (§15). The original messages +apply — the Space's, the chat's, or your Custom instructions (§16). The original messages aren't deleted — they stay in the transcript for scrollback, marked with a compaction divider, but are dropped from what the model sees on the next turn so the freed context goes to new conversation. The popover button is @@ -439,7 +489,7 @@ exactly like every other turn. - **Unpin this response** lives in the same `…` menu; the bar disappears with the last pin. - Pins are stored with the chat, so they survive restarts and travel in - Data backups (§13). + Data backups (§14). ### 2.12 Sharing a message @@ -474,24 +524,33 @@ context. - **Instructions** — a system prompt that *overrides* any global or per-chat prompt when set (the Space is the user explicitly opting into space-level guidance). -- **Reference sources** — text files, PDFs and DOCX documents are inlined - into the system prompt of every chat in this Space; images ride along - with the user turn. **20 MB** per file, **200 MB** total per Space. +- **Reference sources** — on the **Sources** tab: files uploaded to the + Space (text files, PDFs and DOCX documents; **20 MB** per file, **200 MB** + total per Space) and Library items or datasets linked to it (§4). Every + chat in the Space gets them, as full text or as the most relevant + excerpts — the tab shows how much of the context window they take, and the + Space's mode is set on its **Settings** tab (§4.4); images ride along with + the user turn. A switch on + each source keeps it in the Space without using it. Uploads in formats + Loach can't read (spreadsheets, archives, legacy `.doc`) are kept but + marked **unreadable** and never sent. - **Memory** — auto-extracted one-line facts about the user (see §3.3). - **Default provider and model** — pinned per-Space so a "code review" Space can always start in a different model than a "writing" Space. ### 3.2 Lifecycle -- Create from the **Spaces** library (**New space**). +- Create from the **Spaces** library (**New Space**). - Edit name and description from the Space's `⋯` menu → **Edit details**; - instructions and the default model live on their own tabs. + instructions live on their own tab, and the default model, memory + auto-save and source mode together on the **Settings** tab. - Open a Space to see its detail view — **Chats**, **Instructions**, - **Sources**, **Memory** and **Models**, each on its own tab. Each chat row + **Sources**, **Memory** and **Settings**, each on its own tab. Each chat row in the Chats tab exposes the same `…` action menu as the main sidebar (Pin this chat / Unpin, Label, Rename, Move to archive, Delete). -- **Delete space** (same `⋯` menu) removes its instructions, sources and - memories. Its chats stay, but leave the Space. +- **Delete Space** (same `⋯` menu) removes its instructions, uploaded + sources and memories; Library items linked to it stay in the Library. Its + chats stay, but leave the Space. ### 3.3 Space Memory @@ -520,7 +579,8 @@ rewrite or remove them, only avoid repeating them. clear the whole list — while a search is typed, **Clear** removes only the facts it shows. Facts too old to fit the prompt (below) are marked *not sent*. -- **Per-Space toggle** — turn extraction off without wiping existing rows. +- **Per-Space toggle** — on the Space's **Settings** tab (the Memory tab + shows whether it's on); turn extraction off without wiping existing rows. Existing memories continue to ride along in every chat; automatic extraction, the Memory tab's **Add** and `/remember` stop. - **Coverage** — a reply that finishes while more messages are queued, or @@ -558,7 +618,152 @@ Private Chat never reads or writes memory of either kind. --- -## 4. Snippets +## 4. Library + +The **Library** (sidebar) keeps the texts, files and links you give your +models in one place — saved once, used in any chat or Space. Sources are +linked, not copied: edit an item in the Library and every chat and Space +using it sees the new version on its next message. + +### 4.1 What it holds + +The page has a tab for each kind, and the button in the header adds to +whichever tab is open. The page itself lists everything; to search the +Library, use the search palette (**Cmd/Ctrl+K**, §10), where `in:library` +narrows the results to Library items and datasets. + +- **Texts** — written or pasted in the app (**New text**; Markdown works), + editable at any time. +- **Files** — **Upload files**, or drop them anywhere on the page: text and + code files, PDFs and Word documents (their text is extracted, as for + attachments — §2.3) and images. **20 MB** per file; formats Loach can't + read (spreadsheets, archives, legacy `.doc`) are turned away with a note. +- **Links** — **Add link** fetches a web page once and keeps its text as a + dated snapshot. **Refresh** fetches it again; **Open in browser** opens + the original. The fetch follows the web-fetch rules (§9.1): http and https + only, no `localhost` or private-network addresses, and text pages only — a + link to a PDF has to be downloaded and uploaded under Files. Adding a link + that's already in the Library refreshes the copy you have. +- **Datasets** — named groups of texts, files and links, like "Thesis + research", so a whole set attaches in one click. Each dataset has its own + page (with a breadcrumb back to the Library) for adding, uploading straight + into it, and removing items. Items stay in the Library — a dataset only + groups them, and an item can sit in several. + +A text body keeps up to **200,000 characters**; anything longer is cut, +marked **Truncated**, and ends with a note telling the model it's +incomplete. Uploading a file whose content is already in the Library asks +before storing it twice. + +### 4.2 Managing items + +- Click an item to **preview** it: the full text (or image), when it was + added or fetched, and the datasets it's in. The title is renamed in place + (Enter saves, Esc cancels). +- Each item's `⋯` menu has **Preview**, **Edit** (texts), **Refresh** and + **Open in browser** (links), **New chat**, **Add to dataset**, **Add to + Space** and **Delete**. +- Tick several items — across tabs, or a whole tab with **Select all** — to + act on them together: **Add to dataset** (a new or an existing one), **Add + to Space**, **New chat**, **Delete**. +- **Delete** asks first and names the datasets, Spaces and chats that use the + item, directly or through a dataset; it's removed from all of them. + Deleting a dataset keeps its items. +- **Save to Library** from elsewhere: a reply's `…` menu saves the reply as a + text, a user message's menu saves its attachments, and a file uploaded to + a Space can be saved from its `⋯` menu on the Space's **Sources** tab. + Saving something that's already there just says where it is. + +### 4.3 Using sources in a chat + +- In the composer, **`+` → From Library** opens a picker of datasets and + items, or type `#` and a few letters to pick one inline (`Tab`, or the + arrow keys and `Enter`, attaches it; `Esc` dismisses the list). Picked on + the welcome screen, sources attach to the chat once it's created. +- **New chat** — on a text or dataset card, a dataset page or a selection — + starts a new chat with them already attached. +- Attached sources show as chips above the message box, with ✕ to remove + one. A chat in a Space also gets the Space's sources (§3.1), shown + alongside but managed on the Space's **Sources** tab. +- Forking a chat (§2.6) keeps its sources. Private Chat (§2.8) has none. + +### 4.4 How sources reach the model + +The **Sources** chip next to the message box sets how this chat uses them +(the same choice sits on a Space's **Settings** tab, and a chat in a Space +follows it until you pick something else): + +- **Auto** (default) — the full text while all the sources fit in half the + context window, the most relevant excerpts once they don't. +- **Full text** — always the whole text. If it can't fit (sources may take + up to about 80% of the window) it's cut short, and the reply's sources + chip says so. +- **Excerpts** — always search: for each message, the passages most + relevant to it (and to your previous question, so a short follow-up still + finds its subject) are added to that message, up to about a quarter of the + window. They're picked again for every message: they aren't saved into + your message or sent again on later turns. + +New Spaces start on **Auto**. Spaces created before Loach 1.6 — and those +restored from an older backup — start on **Full text**, which is what they +did before (their chats follow it); switching them to **Auto** is up to +you. + +Full text goes into the system prompt and stays the same from turn to turn, +so a local model's prompt cache keeps it. Image items are attached to the +message as images, for vision models. The window is the chat's context +length (§8); the context usage bar (§2.10) counts what the sources will +actually cost. An OpenAI-compatible server picks its own window, so for +those chats Loach assumes 32k (§1.2); set the context length to the +model's real window if it differs: it isn't sent, but it's what sources +are sized by. When full text has to leave something out, the +chat's own sources go in first and the Space's after them; any source that +didn't fit is named in the reply's sources chip. + +Every reply that drew on sources carries a chip above it — "Read 3 sources +in full" or "Used 6 excerpts from 3 sources". Expand it to see which +sources (click a Library one to preview it) and the excerpts themselves; +warnings show there too, such as sources cut to fit or an embedding model +that couldn't be reached. + +### 4.5 Search by keyword and by meaning + +Every text source is split into passages of about 1,200 characters and +indexed for **keyword search** the moment it's added — no setup, no +network. Accents are folded, so `gesla` finds `gęślą`. Chinese, Japanese and +Thai text isn't split into words, though, so keyword search rarely matches +it; use an embedding model for those languages. + +**Settings → Library** adds an **embedding model** for **search by +meaning**, which finds passages that answer a question even when they don't +share its words. It runs on Ollama — with suggested models +(`embeddinggemma`, `embeddinggemma-2:270m` — newer and better with code, on +a recent Ollama — `qwen3-embedding:0.6b`, `bge-m3`, `nomic-embed-text`) to +**Download & use** in one click — or on the OpenAI-compatible endpoint from +**Settings → Providers** (`text-embedding-3-small` / `-large`). Indexing runs +in the background, and keyword search keeps working meanwhile. The Library +header and Settings show where it stands: **Keyword search only** without a +model, **Indexing n / m**, **Search by meaning** once done, or **Embedding paused** with the reason (and **Retry**) +when the model can't be reached — searches then fall back to keywords and +the reply's sources chip says so. After a message's search couldn't reach +the model, the next minute's messages go straight to keywords instead of +each waiting on it. A passage the model refuses or fails on by itself is +set aside for keyword search rather than holding up the rest. + +Changing the model, or the server it runs on (the base URL), re-embeds +every source. **Rebuild** re-embeds everything too, for example after +replacing a model with a new version under the same name. + +The two rankings are merged; when nothing matches at all, the model gets +the opening part of each source instead. + +With an OpenAI-compatible embedding endpoint, the text of your sources — +and of each message that searches them, in chats with a local model too — +is sent to that endpoint. With Ollama it stays on your machine. + +--- + +## 5. Snippets A **Snippet** is a saved prompt with an optional pinned provider/model. Live in the **Snippets** sidebar tab. @@ -571,10 +776,10 @@ in the **Snippets** sidebar tab. messages offers **Save as Snippet**, which prefills the editor with that text. - **Library view** — tile grid sorted by recency, with the static-variables - panel (§4.1) above it. + panel (§5.1) above it. - **Run / Edit / Delete** behind a per-tile `⋯` menu. -### 4.1 Snippet variables +### 5.1 Snippet variables Snippet bodies can contain `{{PLACEHOLDER}}` variables (uppercase names) that get filled in when the snippet is expanded: @@ -583,19 +788,19 @@ that get filled in when the snippet is expanded: Snippets library (e.g. `{{TEAM_NAME}}` → "Engineering"). They're substituted automatically every time a snippet that references them is run. The built-in template variables (`{{USER_NAME}}`, - `{{CURRENT_DATE}}`, …; see §15) are reserved and can't be redefined. + `{{CURRENT_DATE}}`, …; see §16) are reserved and can't be redefined. - **Prompt-on-use placeholders** — any `{{PLACEHOLDER}}` left unresolved after the static pass opens a small fill-in dialog when you run the snippet. Your answers are remembered per snippet for next time. --- -## 5. Models +## 6. Models The **Models** sidebar tab is a full management surface for the local Ollama catalog plus a read-only listing of the OpenAI catalog. -### 5.1 List view +### 6.1 List view - Tiles for every installed model: family and on-disk size. Parameter size, quantization and format show in the editor header. @@ -605,7 +810,7 @@ catalog plus a read-only listing of the OpenAI catalog. - **Open** any Ollama model to edit it in the **Models editor** — click the tile, or **Customize** in its `⋯` menu. -### 5.2 Models editor +### 6.2 Models editor For a single Ollama model, the editor lets you: @@ -615,17 +820,18 @@ For a single Ollama model, the editor lets you: - **Save as new model** writes a derived model via `POST /api/create` (with a **Preview Modelfile** toggle) under the tag you type — a new `<name>-custom` tag is suggested. If a model with that tag already - exists, Ollama replaces it without asking — to keep the original to - revert to, save under a tag that isn't taken. + exists — the base model itself included — Loach asks before Ollama + replaces it; to keep the original to revert to, save under a tag that + isn't taken. - **Duplicate…** (in the tile's `⋯` menu on the list view) — copy under a - new tag without changes. + new tag without changes, asking first if that tag is taken. - **Delete** — irreversible removal of the local copy. - **Allow thinking step** — per-model override for the Thinking toggle. Sits between the Modelfile default and per-chat overrides. Remembered until Loach restarts. - **New chat** pre-selected to this model. -### 5.3 Modelfile guardrails +### 6.3 Modelfile guardrails The **Save as new model** form refuses to compile a Modelfile that would inject additional directives via a malicious base tag, system block, or template @@ -638,11 +844,11 @@ block-end). --- -## 6. Personas and tones +## 7. Personas and tones Two style layers that compose with the chat's system prompt at send time: -### 6.1 Personas (role) +### 7.1 Personas (role) Pick from a curated list of preset roles that pre-pend a system prompt: @@ -658,7 +864,7 @@ persisted across launches by design: the persona text is layered into the system prompt at send time and never written to the chat, so after a restart you pick it again. -### 6.2 Tones (style) +### 7.2 Tones (style) Style modifier appended *after* the persona / instructions: @@ -682,7 +888,7 @@ every tone, so the default-tone picker doubles as a reference. --- -## 7. Generation parameters +## 8. Generation parameters Every chat has a slide-out **parameters panel** on the right. Two modes: @@ -714,13 +920,13 @@ A **Reset to defaults** button below the sliders (labelled **Reset to model defaults** when the model ships Modelfile defaults) clears the per-session overrides and falls back to the merged defaults. -### 7.1 Thinking toggle +### 8.1 Thinking toggle Only meaningful for Ollama models whose `capabilities` include `"thinking"`. Sets the `think` parameter on `/api/chat`; ignored by OpenAI providers. The default for new chats comes from **Settings → Features → Thinking**. -### 7.2 Low VRAM toggle +### 8.2 Low VRAM toggle Ollama-only. Forces smaller batches and a leaner KV cache. The per-chat toggle is overridden when **Settings → Features → Low VRAM mode** is on — @@ -729,7 +935,7 @@ setting. --- -## 8. Tools +## 9. Tools Capabilities Loach offers to the models. Opt-in in **Settings → Tools** (MCP servers in **Settings → MCP**). @@ -742,7 +948,7 @@ Each tool result the model gets back is capped at 32 KB, with a note saying so — the chat shows the whole result (up to 256 KB for an MCP tool). -### 8.1 Web fetch +### 9.1 Web fetch When a prompt contains an `http(s)://` URL, Loach downloads the page, strips HTML to readable text, and appends it as a fenced block. @@ -757,13 +963,16 @@ strips HTML to readable text, and appends it as a fenced block. land on loopback, RFC1918 private ranges, link-local, carrier-grade NAT, or another reserved range. A hostname that DNS-resolves to a private address is rejected. Redirects are walked manually and re-screened per hop. + Loach connects straight to the screened address and never through a + proxy (`HTTP(S)_PROXY` or the system's), since a proxy would look the + host up again itself. - **A failed URL doesn't block the send** — the reply's tool-call block shows the failure and why, and the model gets a short `Failed to fetch <url>: …` stub so it knows Loach tried. - **Shown on the reply** — the URLs Loach fetched for a turn appear as small chips on the assistant message. -### 8.2 MCP (Model Context Protocol) +### 9.2 MCP (Model Context Protocol) Loach speaks two MCP transports. Configure servers in **Settings → MCP**. @@ -773,7 +982,8 @@ Loach speaks two MCP transports. Configure servers in **Settings → MCP**. `localhost` and private LAN addresses are allowed, since self-hosted servers commonly live there). Headers go through size and character checks, each response is capped at 4 MiB, and the per-request - timeout is 30 s. + timeout is 30 s. Like web fetch (§9.1), Loach connects to the screened + address directly, never through a proxy, and doesn't follow redirects. - **Local process (stdio)** — a command (`npx`, `uvx`, `node`, or a full path), one argument per line, and optional `NAME=value` environment variables layered over Loach's own. Loach starts the program and @@ -791,8 +1001,11 @@ Loach speaks two MCP transports. Configure servers in **Settings → MCP**. out of time fails, but the server keeps running — it may still be working on it — and is told the result is no longer wanted (`notifications/cancelled`), as it is when you press **Stop** mid-call. + A server that leaves two requests in a row unanswered counts as hung, + and the next call starts it afresh. A single reply over 4 MiB fails that call only — the server keeps - running, and the model is told to ask for less at a time. + running, and the model is told to ask for less at a time. An error reply + (bad arguments, an unknown tool) likewise fails only that call. When the process exits or stops accepting input, **Test connection** quotes the last lines of its stderr so a failed launch says why (a timeout doesn't include them). In a chat the error stops short of the @@ -806,6 +1019,10 @@ Loach speaks two MCP transports. Configure servers in **Settings → MCP**. are found even when Loach was started from the Dock or an app menu. A `PATH` set in the server's environment replaces both. +On either transport, a tool call whose server reports progress on it +(`notifications/progress`) gets a fresh 30 s with each report, up to 10 +minutes in all, so a long tool that says it's still working isn't cut off. + For each server: - **Display name** — the label shown in menus. @@ -847,10 +1064,14 @@ same rules: one line, at most 100 characters, nothing invisible. Environment variables are treated like headers: they hold API keys, so they are scrubbed from exports, exactly like HTTP headers. Arguments are -exported as typed, except a password in a URL -(`postgresql://user:…@host`) and the value of a flag like `--token` or -`--api-key=…`, which read `REDACTED` — re-enter them after an import. An -environment variable is still the better place for a secret. +exported as typed, except a URL's credentials (the password in +`postgresql://user:…@host`, a token before the `@`, a `?api_key=` value), +the value of a flag like `--token` or `--api-key=…`, and an environment +assignment or header whose name says it holds a credential +(`-e GITHUB_TOKEN=…`, `--header "Authorization: …"`), which read +`REDACTED`. An HTTP server's URL loses its credentials and every query +value the same way. Re-enter them after an import. An environment +variable is still the better place for a secret. #### Per-call approvals @@ -875,29 +1096,35 @@ looking at, a notice says so with a way there, and a chat queued behind it names the chat it's waiting on. Turning **Ask before each tool call** off on a server skips the prompt for all of its tools; the server row shows an *Auto-approve* badge so the exception stays visible. Built-in tools -(§8.3) never ask — they run in-process, with no network access — except -the four workspace file tools that change files (§8.4), which ask unless +(§9.3) never ask — they run in-process, with no network access — except +the four workspace file tools that change files (§9.4), which ask unless you've allowed them for the chat. -### 8.3 Built-in tools +### 9.3 Built-in tools A set of small, local utilities the model can call mid-answer through the same tool-call loop as MCP. Each has its own toggle in **Settings → Tools** and all of them are **off by default** — turn on only what you want a given model to reach for. They run entirely in Rust on your machine -(no network, no disk writes beyond a PDF you ask for), so they also work -inside Private Chat. The one exception is the workspace file group in -§8.4, which does touch the disk — inside a folder you pick — and is never -offered in Private Chat. +(no network, no disk writes beyond a PDF you ask for). The one exception is +the workspace file group in §9.4, which does touch the disk — inside a +folder you pick. None of them is offered inside Private Chat (§2.8). -- **calculate** — evaluate arithmetic, trig, functions, and constants. +- **calculate** — evaluate arithmetic, trig, functions, and constants; + results carry 12 significant digits (as do **unit_convert**'s). - **datetime** — parse, format, and do arithmetic on dates/times, with - timezone conversion and business-day counting. -- **count** — count characters, bytes, words, lines, or substring hits. + timezone conversion (a time without an offset is read in the zone the + model names, UTC by default) and business-day counting. Differences + count whole periods elapsed — a year only once the same date comes + round — and days as wall-clock days in the chosen zone. +- **count** — count characters (Unicode scalar values), user-perceived + characters (graphemes — an emoji with a skin tone, a flag or an accented + letter counts once), bytes, words, lines, or substring hits. - **hash** — SHA-224/256/384/512 over UTF-8, hex, or base64 input. - **uuid** — generate v4 or v7 UUIDs (up to 100 per call). - **base64** — encode/decode with the standard or URL-safe alphabet. -- **json** — validate, pretty-print, or pull values out by JSON Pointer. +- **json** — validate, pretty-print, or pull values out by JSON Pointer, + keeping object keys in their original order. - **unit_convert** — convert between units across nine categories (length, mass, volume, speed, time, area, energy, pressure, temperature). - **diff_text** — unified diff between two strings, by line, word, or @@ -905,12 +1132,14 @@ offered in Private Chat. - **sort** — sort lines (lexical, natural, or numeric; reverse / unique). - **ip** — CIDR membership checks and subnet info. - **pdf** — generate a downloadable PDF from a structured spec (headings, - paragraphs, lists, tables, page breaks). The result lands in the chat as - a previewable, savable attachment. + paragraphs, lists, tables, page breaks), up to 200 pages. The result + lands in the chat as a previewable, savable attachment. The bundled font + covers Latin-script text only: Cyrillic, Greek, CJK and emoji print as + `?`, and the tool tells the model how many characters that hit. Calls render in the collapsible tool-call block described in §2.1. -### 8.4 Workspace files +### 9.4 Workspace files Give a chat a folder and the model can work in it — read the project, search it, and make changes — the way a coding assistant does, minus the @@ -946,7 +1175,10 @@ the text-direction override and isolate characters anywhere (a right-to-left override can make `txt.exe` look like `exe.txt` on the approval card), and on Windows a `:` (a hidden data stream such as `setup.exe:Zone.Identifier`), a reserved device name (`CON`, `nul.txt`, `COM1`) or a name ending in a dot or a -space — Windows tools can't open or delete those afterwards. +space — Windows tools can't open or delete those afterwards. A path that +reaches a file through its Windows 8.3 short name (`IMPORT~1.MD` for +`important-notes.md`) is refused too, so the approval card names the file +that would actually change. - **list_directory** — tree of a directory, two levels by default, with dependency and build directories (`node_modules`, `target`, `.git`, …) @@ -996,17 +1228,21 @@ it will be written. you change or remove its folder, delete the chat, switch the workspace tools off, or quit Loach (the grant is kept in memory, is tied to the folder it was given for, and covers only Loach's own tool — never an MCP -server's tool of the same name). It never covers anything inside a `.git` -directory: a change there shows in no `git status` or diff, and a hook -or setting there runs commands the next time you use git, so those -always ask. **Deny** tells the model to carry on without it. Line -endings are preserved: editing or overwriting a CRLF file keeps it CRLF. +server's tool of the same name). It never covers a place git runs +commands from the next time you use it, so changes there always ask: +anything inside a `.git` directory (which also shows in no `git status` +or diff), the hook folders `.husky` and `.githooks`, a +`.pre-commit-config.yaml`, and any folder laid out as a bare repository +(`HEAD` beside `objects` and `refs`). **Deny** tells the model to carry on +without it. Line endings are preserved: editing or overwriting a CRLF file +keeps it CRLF. Writes and edits replace the file rather than rewriting it in place. A file that is also hard-linked from elsewhere — a pnpm or uv package store, say — gets its own new copy, and the other links keep the old contents; a write that fails partway leaves the old file intact. Permissions carry -over (a script stays executable), and a read-only file is refused. +over (a script stays executable; on Windows a file's own access rules are +copied to the new copy), and a read-only file is refused. So is the text encoding. Files that aren't UTF-8 — Windows-1250 or another legacy code page, or UTF-16 with a byte-order mark (what PowerShell 5.1 @@ -1048,18 +1284,29 @@ tools at a folder other than through the native picker. --- -## 9. Search palette +## 10. Search palette Press **Cmd/Ctrl+K** (or click the title-bar search pill) to open a global command palette: -- Cross-searches **chats**, **messages**, **spaces**, and **snippets**. -- Empty query shows recent chats, spaces and snippets as suggestions. +- Cross-searches **chats**, **messages**, **spaces**, **snippets** and the + **Library**. +- Empty query shows recent chats, spaces, snippets and Library items as + suggestions. - Arrow keys to move, Enter to commit, Esc to dismiss (or to clear a non-empty query first). - Picking a chat opens it. Picking a Space opens its detail view. Picking a Snippet starts a fresh chat with the snippet's prompt primed. Picking - a message opens its chat and jumps to that turn. + a message opens its chat and jumps to that turn. Picking a Library item + opens its preview where you are; picking a dataset opens its page in the + Library. + +This is the Library's only search (its page has no box of its own): +datasets match by name and description, items by title, the opening of +their text (about 280 characters) and a link's address — not by the whole +body; searching inside sources is what a chat does with them (§4.5). When +many kinds match, the slots are shared out between them so chat titles +can't push Library hits off the list. **Message search** looks inside transcripts, not just titles. Hits carry a `MESSAGE` badge and an excerpt that opens just before the match, and sort @@ -1078,18 +1325,20 @@ matches transcript text at its own case but not across cases (title matching is fully case-folded). **Scoping.** A dropdown beside the input narrows to one kind: *everywhere* -(the default), *chats*, *messages*, *spaces* or *snippets*. It works by -writing an `in:<scope>` token into the query itself — the token *is* the -scope, so you can type `in:messages borrow` by hand, or `notes in:spaces` -with the token last, and the dropdown label follows either way. Clearing the -token from the text returns to *everywhere*. +(the default), *chats*, *messages*, *spaces*, *snippets* or *library*. It +works by writing an `in:<scope>` token into the query itself — the token +*is* the scope, so you can type `in:messages borrow` or `in:library thesis` +by hand, or `notes in:spaces` with the token last, and the dropdown label +follows either way. Clearing the token from the text returns to +*everywhere*. The palette is suppressed while onboarding, the lock screen or Private -Chat owns the window. +Chat owns the window, and while another dialog (Settings, a confirmation) +is open. --- -## 10. Code canvas +## 11. Code canvas Inline code blocks in assistant messages get an **Open** button (tooltip "Open in canvas"). Clicking opens a right-side panel: @@ -1112,13 +1361,35 @@ Inline code blocks in assistant messages get an **Open** button (tooltip canvas keeps mirroring the latest code block as more tokens arrive. - **Resizable** — drag the left edge to set the panel width; the choice is remembered. +- **HTML preview** — for HTML (an `html` code block, or an `.html` / + `.htm` attachment) a **Code / Preview** switch renders the page, and + HTML code blocks get a **Preview** button that opens the canvas straight + on it. The page shows with its **scripts off**: markup and styles only, + enforced by the preview's own security policy (inline scripts, event + handlers and `javascript:` links are refused). **Run scripts** turns them + on for that page — any other code block or edit starts off again — and + then scripts and form handlers work; **Reload** starts the page over. On + macOS and Linux the preview runs on Loach's own thread, so a script that + never stops freezes the app until you quit it; the button's tooltip says + so. On a reply that's still streaming, the preview appears once the code + block is complete. + + The page runs in a sandbox, on an origin of its own: it can't reach + Loach, your chats or your files, can't open windows or navigate away, + and runs **offline** — scripts, stylesheets, images and fonts linked from + the web don't load, so a page built on a CDN library (Tailwind, + Chart.js, …) shows without it. `localStorage` and `sessionStorage` + work but last only until the page reloads; cookies aren't available, and + `alert()` does nothing. Frames inside the page (`<iframe>`, `<object>`, + `<embed>`) are removed as they're added, and WebRTC isn't available (on + Windows the engine itself also refuses it the UDP it would need). The canvas and the parameters sidebar share the right slot — the canvas wins when both would be open. --- -## 11. Appearance +## 12. Appearance In **Settings → Appearance**: @@ -1129,16 +1400,18 @@ In **Settings → Appearance**: - **Font size** — Small, Normal, Large. Applied as a CSS scale to both rem-based and pixel-based text sizes. -Background style and color mode update instantly. The title bar shows the -window controls (minimise / maximise / close) inline since the window is -borderless on all three platforms. +Background style and color mode update instantly. On Windows and Linux the +window is borderless and the title bar draws its own window controls +(minimise / maximise / close); on macOS the window keeps the native +traffic-light buttons, overlaid on the title bar (and hidden in full +screen, where the bar takes the room back). --- -## 12. App lock (Security) +## 13. App lock (Security) Optional credential gate that runs before any chat data hydrates, with -opt-in triggers (§12.4) that re-engage it mid-session. Configure in +opt-in triggers (§13.4) that re-engage it mid-session. Configure in **Settings → Security**. - **PIN** (4, 6, or 8 digits), **Password** (at least 8 characters), or @@ -1148,28 +1421,36 @@ opt-in triggers (§12.4) that re-engage it mid-session. Configure in - **Argon2id** hashing in Rust; the lock blob lives in the OS credential store (Windows Credential Manager / Linux Secret Service / macOS Keychain), never on disk in plaintext, never in SQLite. +- **Marker file** — a non-secret copy of the lock's shape (whether one is + set, its method, PIN length, and whether it has a hint) sits next to the + database as `app_lock.json`, so the lock screen comes up at launch + without reading the credential store (and without a keychain prompt). + The credential store is read when you unlock; a read that fails or is + refused keeps Loach locked. -### 12.1 Lock screen +### 13.1 Lock screen Fills the window until the user authenticates. PIN field first when both are required (fastest to type on a numeric pad). Wrong attempts clear the PIN field (the password field is kept so the user can fix a typo). -### 12.2 Rate limiting +### 13.2 Rate limiting After 5 consecutive wrong credentials — at the lock screen or in any -re-authentication prompt (§12.3) — every credential check is refused for an +re-authentication prompt (§13.3) — every credential check is refused for an escalating window (30 s, 60 s, 2 min, … capped at 2 h). The count resets on -any successful check, when the lock is set or removed, and on app restart. +any successful check and when the lock is set or removed. It's saved next +to the database (`app_lock_attempts.json`), so restarting Loach doesn't +reset it. -### 12.3 Re-authentication for destructive actions +### 13.3 Re-authentication for destructive actions Changing or removing the lock and the destructive Data commands (import, wipe, factory reset) require the user's *current* credentials even though the app is unlocked, so a compromised renderer cannot silently disable the gate or trigger a wipe. -### 12.4 Auto-lock +### 13.4 Auto-lock On its own the lock only gates *launch*: clear the lock screen once and you stay unlocked until you quit. That covers a stolen laptop but not the threat @@ -1187,14 +1468,20 @@ triggers close it, both in the **Auto-lock** card that appears in otherwise hidden. Keyed on visibility, not focus, so a native save / open dialog (export, import, attaching a file) doesn't lock you out mid-task. -**`Cmd/Ctrl + Shift + L`** locks immediately from anywhere in the app, -whether or not either trigger is on. Re-locking never touches the backend — -it flips the render gate, and the credentials were never in the renderer to -begin with. +**`Cmd/Ctrl + Shift + L`** (or `/lock`) locks immediately from anywhere in +the app, whether or not either trigger is on. On macOS, closing the window +— which hides Loach there rather than quitting it — always locks it too. +Re-locking never touches the backend — it flips the render gate, and the +credentials were never in the renderer to begin with. + +Locking closes any open confirmation dialog and drops pending toasts (their +**Undo** included), and ends a Private Chat session, wiping it as closing it +would (§2.8). Unlocking doesn't re-run startup: the open chat and its +draft are where you left them. --- -## 13. Data management +## 14. Data management **Settings → Data** is where backups, restores, and cleanups live. @@ -1202,9 +1489,11 @@ A read-only **storage breakdown** sits above the controls, so "how big is this?" is answered before you reach for a destructive one: - **Database** — total bytes on disk (the SQLite file plus its write-ahead - log and shared-memory file) and the path, itemised into chats (message - text, with message and chat counts), attachments inlined into messages and - snippets, Spaces (with source counts), and **Other** (snippets, snippet + log and shared-memory file) and the path, itemised into chats (messages, + their reasoning and tool calls included, with message and chat counts), + attachments inlined into messages and + snippets, Spaces (with source counts), the Library (its items and the + search index built over them), and **Other** (snippets, snippet variables and saved fill-ins, global memories, MCP servers, settings, and each chat's title, instructions and parameters). - **Local models** — how many Ollama models are installed and what they @@ -1214,20 +1503,31 @@ this?" is answered before you reach for a destructive one: Below it sit the actions: - **Export data** — produces a single JSON blob with every chat, message, - folder, Space, source, memory, snippet, snippet variable, MCP server, and - setting. MCP headers and environment variables are scrubbed, and so are - secrets passed as arguments (§8.2); chats leave out their workspace folder - (§8.4). The save dialog and the file write happen in Rust; the panel - confirms the path it saved to. + folder, Space, source, memory, Library item and dataset (and which chats + and Spaces use them), snippet, snippet variable, MCP server, and setting. + The Library's search index is left out; Loach rebuilds it after a restore. + MCP headers and environment variables are scrubbed, and so are + secrets passed as arguments and an HTTP server URL's credentials and + query values (§9.2); chats leave out their workspace folder + (§9.4). The save dialog and the file write happen in Rust; the panel + confirms the path it saved to. Exports are tagged `loach/v2`: Loach 1.5 + and older refuse them rather than restore them without the Library, + while this version still restores the `loach/v1` backups those made. - **Import data** — open a previously exported JSON. It **replaces** - everything in the database (the stored OpenAI key is kept), shows a count - of the chats, messages, Spaces, snippets, variables and MCP servers that - came in, then reloads. Stdio MCP servers arrive disabled (§8.2). Requires + everything in the database — the Library and memories included; a backup + from before the Library existed leaves it empty — shows a count + of the chats, messages, Spaces, Library items and datasets, snippets, + variables and MCP servers that came in, then reloads. The stored OpenAI + key is kept, and so is the current OpenAI base URL while a key is stored, + so the key never goes to a server a backup names. Stdio MCP servers + arrive disabled (§9.2); a server name that breaks the naming rules is + fixed up rather than failing the import. Requires current app-lock credentials when a lock is configured. - **Archive all chats** — parks every live chat in the archive (§2.7). - **Erase & Reset → Remove my data** — drops chats, folders, Spaces (with - their sources and memories), global memories, snippets, snippet variables - and saved fill-ins, and MCP servers, but keeps app settings and the stored + their sources and memories), the whole Library (texts, files, links and + datasets), global memories, snippets, snippet variables and saved + fill-ins, and MCP servers, but keeps app settings and the stored OpenAI key. Gated on the app-lock credentials, and on typing `YES`. - **Erase & Reset → Factory reset** — the same wipe + clear all settings + remove the OpenAI key and the app lock from the credential store. @@ -1236,7 +1536,7 @@ Below it sit the actions: --- -## 14. Onboarding +## 15. Onboarding A six-step wizard runs on first launch (and after a factory reset). Each step writes its choice straight into settings, so dismissing partway through @@ -1256,7 +1556,7 @@ still leaves the app in a consistent state. utilities open on a **Recommended** preset — everything on except the four only a developer asks for (hash, UUID, base64, IP/CIDR) and Workspace files, which switches itself on when a chat gets a folder - (§8.4) — and **All OFF / Recommended / All ON** buttons flip the set at + (§9.4) — and **All OFF / Recommended / All ON** buttons flip the set at once. Because the screen *shows* a selection, the write rules differ from Features: **Continue** commits the utilities exactly as displayed, while @@ -1274,7 +1574,7 @@ The wizard does not ask for a display name. `{{USER_NAME}}` still resolves from Settings → General; it just isn't collected up front, because the value only pays off for users who go on to write it into a custom instruction. -### 14.1 Choosing a model +### 15.1 Choosing a model When Ollama is running but has no models, the step reads the host's capacity (`system_info`) and leads with a single recommendation — the largest catalog @@ -1297,8 +1597,9 @@ on small-RAM/big-GPU ones. Detection lives in `src-tauri/src/gpu.rs`: - **macOS** — deliberately none. Apple Silicon is unified memory, so system RAM already *is* the GPU budget and a separate figure would double-count it. -Adapters under 1 GB of dedicated memory — and, on Windows, any adapter that -reports unified memory — are ignored as integrated graphics, which carve out +Adapters under 1 GB of dedicated memory — and any adapter that reports +unified memory on Windows, or whose amdgpu metrics mark it as an APU on +Linux — are ignored as integrated graphics, which carve out system RAM and are already covered by the RAM path. Every probe is best-effort: anything unreadable falls back to RAM, which stays correct for CPU-only and integrated setups. @@ -1357,7 +1658,7 @@ picker of the models it found; the OpenAI path ranks the endpoint's catalog so a chat model leads (`/v1/models` order routinely puts an embedding or audio model first) and shows the pick in the same picker. -### 14.2 Downloads that outlive the wizard +### 15.2 Downloads that outlive the wizard Pulls started here keep running while the user finishes setup, so the download is surfaced the whole way: @@ -1374,7 +1675,7 @@ download is surfaced the whole way: found. Check the model name and URL" — a wrong and unactionable first impression. -### 14.3 When Ollama isn't reachable +### 15.3 When Ollama isn't reachable The step offers **Start Ollama** (the same `ollama_start` command the chat header uses) before suggesting a download, since "installed but not running" @@ -1385,7 +1686,7 @@ instead of a stale warning they have to know to dismiss. --- -## 15. Custom instructions and template variables +## 16. Custom instructions and template variables The **Custom instructions** textarea in **Settings → General** is applied as the system prompt of every new chat (Spaces and per-chat overrides win @@ -1400,7 +1701,7 @@ authored (custom instructions, Space instructions, snippets, per-chat): - `{{CURRENT_TIMEZONE}}` — IANA zone (e.g. `Europe/Warsaw`) with a UTC offset fallback for very old WebViews. -### 15.1 Temporal awareness +### 16.1 Temporal awareness When **Settings → Features → Temporal awareness** is on, Loach prepends a short preamble with the current date, weekday and timezone (not the time) @@ -1413,7 +1714,7 @@ an example value (`{{USER_NAME}}` isn't in it). --- -## 16. Default model preferences +## 17. Default model preferences **Settings → General → Default model** controls which model new chats land in. Three modes: @@ -1438,7 +1739,7 @@ with every request, so it overrides `OLLAMA_KEEP_ALIVE`. --- -## 17. Updates +## 18. Updates In-app updater for the Tauri-supported install formats: @@ -1483,37 +1784,45 @@ verification happens before the binary is replaced. --- -## 18. Storage and privacy posture +## 19. Storage and privacy posture - **Everything except the OpenAI-compatible API key and the app-lock hash lives in a local SQLite database** under the user's app-data directory - (apart from scratch files written for the canvas's **VS Code** button and - a few layout preferences kept in the web view's local storage). Foreign + (apart from scratch files written for the canvas's **VS Code** button, + a few layout preferences kept in the web view's local storage, and the + app lock's non-secret marker and failed-attempt count — `app_lock.json` + and `app_lock_attempts.json` next to the database, §13). Foreign keys are enforced in normal use; restoring a backup checks message roles, providers, MCP servers and setting keys, but skips foreign-key checks so - older or partial backups still load. + older or partial backups still load — afterwards a chat pointing at a + Space or folder the backup doesn't carry simply leaves it, and rows whose + parent is missing are dropped. - **OpenAI-compatible API key, app-lock hash** — OS credential store (Windows Credential Manager / Linux Secret Service / macOS Keychain via the `keyring` crate). - **MCP credentials are not in the credential store.** A server's HTTP headers (an `Authorization` token, say) and a stdio server's environment variables are stored in the SQLite database as plain text, alongside the - rest of its configuration. They are scrubbed from exports (§8.2), but + rest of its configuration. They are scrubbed from exports (§9.2), but anyone who can read the app-data directory can read them. - **No telemetry** — Loach makes no outbound network requests beyond - the providers the user configures, the optional URL fetches the user - triggers, the MCP servers the user wires up, and the in-app updater - check against the GitHub releases endpoint. + the providers the user configures (an embedding model for the Library + included — §4.5), the optional URL fetches the user triggers (Library + links included), the MCP servers the user wires up, and the in-app + updater check against the GitHub releases endpoint. - **CSP is locked down** — no remote scripts, no inline scripts, no eval. The Tauri global is disabled; the renderer talks to the backend only - through registered commands. + through registered commands. The one place inline scripts run is the + canvas's HTML preview (§11), and only inside its sandboxed frame, which + has its own policy: nothing it loads comes from outside the page, and it + can't reach the network or Loach's commands. - **File I/O is backend-owned** — every save dialog, the Data import - dialog, the workspace folder picker (§8.4), and the actual read / write + dialog, the workspace folder picker (§9.4), and the actual read / write happen in Rust. The renderer can't pick a path itself (it only learns where a backup landed after the write), so a compromised UI cannot read or overwrite arbitrary files. - **Running a program is backend-gated** — a stdio MCP server's command - line is confirmed in a native OS dialog before it can start (§8.2), and + line is confirmed in a native OS dialog before it can start (§9.2), and every MCP tool call asks in the chat before it runs unless you opted that server or tool out. The in-chat approval cards guard against what the model asks for; they're answered in the app window, so the native dialog @@ -1521,7 +1830,7 @@ verification happens before the binary is replaced. --- -## 19. Keyboard reference +## 20. Keyboard reference Press `Cmd/Ctrl + /` to open an in-app cheat sheet of the nine app-wide shortcuts (the first nine below), grouped into Navigation, Chat, Layout, @@ -1529,21 +1838,30 @@ Security and Help; the composer and palette keys after them aren't in it. The dialog reads the same table as the shortcut handler, so the keys listed there are the keys the app actually listens for (`Cmd/Ctrl + K` is handled by the search palette's own listener). The app-wide shortcuts are off -while onboarding or Private Chat is open — except `Cmd/Ctrl + Shift + L` — -and while the lock screen is up. +while onboarding, Private Chat or a dialog (the search palette included) is +open — except `Cmd/Ctrl + Shift + L` — and while the lock screen is up or +Loach is still checking at launch whether there is one. On keyboard +layouts without Latin letters (Cyrillic, Greek) a shortcut follows the +key's position, and `Cmd/Ctrl + /` works where `/` itself needs Shift +(German, French, Nordic layouts). - `Cmd/Ctrl + K` — global search palette. - `Cmd/Ctrl + N` — start a new chat. - `Cmd/Ctrl + F` — find within the current chat transcript. - `Cmd/Ctrl + U` — attach files to the current chat (opens the file picker). - `Cmd/Ctrl + Shift + Backspace` (or `Delete`) — delete the current chat, - after a confirmation. + after a confirmation — only while that chat is on screen, never from + behind another page (a Space, a model, the Library). - `Cmd/Ctrl + Shift + S` — show / hide the left sidebar. -- `Cmd/Ctrl + Shift + P` — show / hide the parameters panel. +- `Cmd/Ctrl + Shift + P` — show / hide the parameters panel (beside an open + chat). - `Cmd/Ctrl + Shift + L` — lock Loach now (when an app lock is configured; - see §12.4). + see §13.4). - `Cmd/Ctrl + /` — open the keyboard-shortcuts cheat sheet. - `/` (start of the composer) — open the slash-command palette. +- `#` (in the composer) — pick a Library source to attach (§4.3); `Tab` + attaches the highlighted one, as does `Enter` after moving with the arrow + keys. - `Enter` — send the composer; in the command palette, complete the highlighted command (or run it once fully typed); in the search palette, open the highlighted result; in the in-chat finder, jump to the next match @@ -1558,17 +1876,26 @@ and while the lock screen is up. --- -## 20. Platform support +## 21. Platform support - **Windows 10/11 x86_64** — NSIS installer, in-app updater. - **Linux x86_64** — AppImage, `.deb`, and `.rpm`, all three with the - in-app updater (§17). Built against **glibc 2.35**, so Ubuntu 22.04 and + in-app updater (§18). Built against **glibc 2.35**, so Ubuntu 22.04 and Debian 12 are the oldest supported distributions. -- **macOS 11+ Apple Silicon** — `.dmg` install plus a `.app` bundle, with - in-app updater. The build is **not Apple-notarized** (we don't subscribe - to the Apple Developer Program), so first launch requires a one-time - Gatekeeper bypass — see the README "Install on macOS" section. Intel - Macs are not supported. +- **macOS 11+ Apple Silicon, with Safari 16.4 or later** — `.dmg` install + plus a `.app` bundle, with in-app updater. Loach's window runs on the + system's WebKit, which follows the installed Safari rather than the macOS + version, and the Markdown renderer needs Safari 16.4's JavaScript — a Big + Sur or Monterey Mac still on Safari 14 or 15 has to update Safari first. + The build is **not Apple-notarized** (we don't subscribe to the Apple + Developer Program), so first launch requires a one-time Gatekeeper + bypass — see the README "Install on macOS" section. Intel Macs are not + supported. + +Loach runs as a single instance: launching it again (the Start menu, a +desktop file, a double click on the app) brings the running window +forward — out of the tray too — instead of starting a second copy over the +same database. System requirements depend on the local model you pick (Loach itself is small — a few hundred megabytes resident); Ollama's GPU requirements diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 3bd6a70..93521a7 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -74,7 +74,9 @@ no Secret Service running. desktop environment. 2. Make sure a login session is unlocked so the keyring is available to user processes. -3. Restart Loach and re-enter the key. +3. Re-enter the key. Loach connects to the Secret Service again on each + try, so a keyring that started after Loach is picked up without a + restart; restart Loach if saving still fails. ### Models list shows no models @@ -132,8 +134,9 @@ above the answer. 3. The Thinking toggle is **Ollama-only**. In a chat on an OpenAI-compatible provider it's disabled and reads off; a thinking block still appears there only if the server streams its reasoning as a - separate `reasoning_content` field (some DeepSeek, vLLM and llama.cpp - setups do). + separate `reasoning_content` or `reasoning` field (some DeepSeek, vLLM, + llama.cpp and OpenRouter setups do, and so does Ollama's own `/v1` + endpoint). ### A formula shows as raw TeX instead of rendering @@ -188,6 +191,20 @@ Tokens** and **Repeat Penalty** only appear in its **Advanced** view: sliders to discard per-chat overrides and fall back to the Space's and model's defaults. +### An HTML preview shows the page but nothing on it works + +**Problem.** In the code canvas, buttons, games or animations in a +previewed page do nothing. + +**Solution.** Previews open with the page's scripts off — markup and styles +only. Click **Run scripts** in the preview bar to turn them on for that +page; another code block, or an edit, starts off again. On macOS and Linux +a script that never ends freezes Loach until you quit it, because the +preview runs on the app's own thread there — only run pages you're willing +to restart for. If a page still misbehaves with scripts on, it may load a +library from the web: previews are offline, so a CDN `<script src>` never +arrives. + --- ## 3. Performance and VRAM @@ -227,9 +244,10 @@ there's no usable GPU reading. Which one you get: choose a variant that fits its VRAM by hand. - **macOS** deliberately uses system RAM: Apple Silicon shares it with the GPU, so a separate figure would double-count. -- Adapters under 1 GB — and, on Windows, any adapter that reports unified - memory — are treated as integrated graphics and ignored, since they - carve out system RAM the RAM path already counts. +- Adapters under 1 GB — and any adapter that reports unified memory on + Windows, or that amdgpu reports as an APU on Linux — are treated as + integrated graphics and ignored, since they carve out system RAM the RAM + path already counts. Any probe that fails falls back to RAM. Nothing here restricts you — every catalog entry stays pullable, and the custom-tag field takes any tag at @@ -373,6 +391,16 @@ not the page content. 3. Up to **5 URLs per message** are followed. Extras are ignored. 4. **Private Chat** never fetches URLs, whatever the setting says. +### The model can't use tools in Private Chat + +**Problem.** A model that calls tools in regular chats says it has none in +Private Chat. + +**Solution.** By design: Private Chat offers no tools at all — no MCP +servers, which could carry the conversation off your machine, and no +built-in tools, since the overlay has nowhere to show a call or a PDF one +makes. Use a regular chat for tool work. + ### "Refusing to fetch …" for `localhost`, `192.168.x`, or my office VPN **Problem.** Loach refuses to fetch an internal URL. @@ -404,7 +432,11 @@ cap and the entry says "truncated by Loach" (so does a page whose extracted text runs past ~12,000 characters). Try the URL in a browser. If it works there but not in Loach, the page is -likely slow, large, or blocks non-browser user agents. +likely slow, large, or blocks non-browser user agents — or your network +only lets traffic out through a proxy. Web fetch, Library links and HTTP +MCP servers connect directly and never use a proxy (`HTTP(S)_PROXY` or the +system setting): a proxy looks the host up again itself, which would undo +the check above. ### MCP "Test connection" fails (HTTP) @@ -422,6 +454,11 @@ an error. 3. Check that the response body fits under **4 MiB** — misconfigured servers that dump full schemas can exceed this. 4. Per-request timeout is **30 s**. +5. Loach doesn't follow redirects. When the error says the server + redirects to the same URL with (or without) a trailing `/`, change the + server URL to match; for any other target, use the address it names. +6. Loach connects directly, never through a proxy — a server reachable + only through one won't answer. ### A local (stdio) MCP server won't start @@ -463,8 +500,10 @@ enabled.) or pressed Enter, which picks Cancel — nothing was saved or started. Save again and click **Start server**. 7. A tool call that times out doesn't stop the server; it may still be - working. If every call times out, disable and re-enable the server to - restart it. + working (a tool that reports progress gets up to 10 minutes). When two + requests in a row go unanswered, Loach treats the server as hung and + starts it afresh on the next call; to restart it sooner, disable and + re-enable it. ### A server imported from a backup is disabled and won't turn on @@ -479,10 +518,14 @@ unless their host couldn't be looked up during the import (offline, off the VPN), in which case they arrive switched off too; turn them on once the host is reachable again. -Backups never contain MCP headers or environment variables, and a password -in a connection URL or the value after a `--token`-style argument comes back -as `REDACTED` — so after an import, re-enter any `Authorization` header, -API-key variable or redacted argument a server needs. +Backups never contain MCP headers or environment variables, and a URL's +credentials, the value after a `--token`-style argument and a credential +passed as `NAME=value` come back as `REDACTED` — as does every query value +in an HTTP server's URL — so after an import, re-enter any `Authorization` +header, API-key variable, URL or redacted argument a server needs. + +A server whose name breaks the naming rules (from a backup made before +them) is imported under a fixed-up name rather than failing the import. ### The reply is stuck on "Waiting for your approval…" @@ -538,7 +581,7 @@ tab. - Memory extraction only runs **after a complete assistant reply**. Cancelled or errored turns are skipped. -- The toggle on the Space's Memory tab must be on. Outside a Space, +- Memory must be on in the Space's **Settings** tab. Outside a Space, memory stays off until you turn on **Settings → Features → Global memories**. - Each candidate fact must be **at most 280 characters**. Longer "facts" @@ -564,7 +607,8 @@ existing memories still ride along until you remove them. Turning off **Solution.** Each file can be at most **20 MB**, and a Space's sources at most **200 MB** in total. Remove -older references, or move the content into a smaller text file. +older references, or move the content into a smaller text file. Library +items linked with **From Library** don't count toward that limit. ### Space instructions seem to override my custom instructions @@ -577,7 +621,122 @@ Space's instructions, or repeat the relevant parts in them. --- -## 7. App lock +## 7. Library + +### Search by meaning says "Embedding paused" + +**Problem.** The Library header or **Settings → Library** shows **Embedding +paused**, and replies' sources chips say the embedding model was +unavailable. + +**Solution.** Loach can't reach the embedding model, so it searches your +sources by keyword until it can — nothing is lost. **Settings → Library** +shows the reason: + +- *model … not found* — the model isn't installed in Ollama. Click + **Download it**, or pick a suggested model with **Download & use**. +- *error sending request* — Ollama, or the OpenAI-compatible endpoint, isn't + running or can't be reached. Start it, check the base URL under + **Settings → Providers**, then click **Retry**. +- The endpoint answers but rejects the model — pick one it serves for + embeddings; a chat model won't do. + +Loach tries again by itself every minute, and straight away when you change +the model or the base URL. Messages sent meanwhile don't wait on the model: +once one couldn't reach it, those in the next minute go straight to +keyword search. + +### Indexing never seems to finish + +**Problem.** **Indexing n / m** stays in the Library header. + +**Solution.** Embedding runs in the background, a batch at a time, and a +large Library on a CPU-only machine takes a while; keyword search works +throughout. If the count stops moving, check **Settings → Library**: when +something fails the status turns into **Embedding paused** with the reason +(see above). Passages a model refuses or fails on by themselves — too long +for some servers, or one that crashes the model's runner — are set aside +for keyword search and don't hold up the rest; editing the source, new +embedding settings or a rebuild gives them another try. **Rebuild** in +**Settings → Library** starts over from scratch. + +Changing the embedding model, or the base URL of the server it runs on, +re-embeds every source, so the count starts over then. + +### A link won't add + +**Problem.** **Add link** fails with an error. + +**Solution.** + +- *Refusing to fetch …* — links to `localhost`, private networks and VPN + addresses are refused, the same as for web fetch (see "Refusing to fetch + …" above). Copy the page's text into a **New text** instead. +- *This link serves `application/pdf`…* (or another file type) — the link + points at a file, not a page. Download it and add it under **Files**. +- *The page has no readable text* — the page builds its content with + scripts, which Loach doesn't run. Copy its text into a **New text**. +- A slow page gets 30 seconds, and anything past 5 MB is cut. + +A link is a snapshot: when the page changes, **Refresh** it. + +### The model ignored my source, or used the wrong part of it + +**Problem.** A reply doesn't reflect a source you attached. + +**Solution.** Expand the sources chip above the reply. It lists the sources +and says whether the model read them **in full** or got **excerpts**. + +- **Excerpts** — only the passages most relevant to your message were sent. + Name the topic in the message, set an embedding model so sources are also + searched by meaning, or switch the chat's **Sources** chip to **Full + text** if they fit. +- **Cut to fit** — the sources didn't fit the context window. Raise the + chat's context length, attach fewer sources, or switch to **Excerpts**. + On an OpenAI-compatible chat Loach assumes a 32k window unless you set + one; set the context length to the model's real window if it's larger. + A Space made before 1.6 stays on **Full text**, as it always worked — + switch it to **Auto** on its **Settings** tab to have long sources + searched once they don't fit. +- **No chip** — the source isn't reaching the chat at all: check it's + switched on (a Space's **Sources** tab) and attached to this chat or its + Space. Private Chat never uses sources. + +Small local models sometimes overlook long context; a larger model helps. + +### Keyword search finds nothing in Chinese, Japanese or Thai text + +**Problem.** Excerpts from these sources don't match the question — the +chip shows the opening part of each source instead. + +**Solution.** These languages don't put spaces between words, so keyword +search can only match long runs of text. Set a multilingual embedding model +in **Settings → Library** (`embeddinggemma`, `qwen3-embedding:0.6b` or +`bge-m3`) — search by meaning doesn't depend on spaces — or switch the chat +to **Full text** when the sources fit. + +### An item is marked "Truncated" + +**Problem.** A Library item shows a **Truncated** badge. + +**Solution.** An item keeps up to 200,000 characters (roughly a 100-page +document); the rest was cut, and the model is told the text is incomplete. +Split the document and add the parts as separate items — put them in one +dataset to attach them together. + +### A Space upload says "unreadable" + +**Problem.** On a Space's **Sources** tab, an upload shows **unreadable** +instead of a token count. + +**Solution.** Loach can't read that format (spreadsheets, archives, legacy +`.doc`), so it keeps the file in the Space but never sends it to a model. +Save it as PDF, DOCX or plain text and upload that instead. The Library +turns such files away outright. + +--- + +## 8. App lock ### I forgot my PIN or password @@ -600,8 +759,12 @@ options are: - **macOS** — in **Keychain Access**, delete the `dev.loach.app` item whose account is `app_lock`. - Your chats and settings are untouched — only the lock is gone, and Loach - starts unlocked on the next launch. + Your chats and settings are untouched — only the lock is gone. Loach + remembers that a lock was set in a marker file next to its database + (`app_lock.json` in the app-data folder), so the next launch still shows + the lock screen once: enter any PIN of the right length (and any + password) to get in, and it starts unlocked from then on. Deleting + `app_lock.json` as well skips that last lock screen. Deleting the app-data folder does **not** help: it wipes your chats but leaves the credential-store entry, so the lock screen comes straight back. @@ -616,8 +779,9 @@ a red *Too many failed attempts. Try again in N seconds* message. **Solution.** After 5 consecutive failed attempts, Loach starts an escalating cool-down (30 s → 60 s → 2 min … up to 2 h). Wait for the -window to expire, or **restart the app** — the counter resets on restart -and on a successful unlock. +window to expire. Restarting the app doesn't help: the count is saved next +to the database and only resets on a successful unlock (or when the lock is +changed or removed). ### Changing or removing the lock asks for my current password @@ -646,10 +810,24 @@ lock is configured: so exporting or attaching a file is safe either way. If neither is on, check you aren't hitting `Cmd/Ctrl + Shift + L`, which -locks immediately. +locks immediately. On macOS, closing the window (red button, `⌘W`) hides +Loach rather than quitting it, and always locks it. A reply already streaming keeps running behind the lock screen — unlocking -returns you to it with the tokens that arrived meanwhile. +returns you to it with the tokens that arrived meanwhile. A Private Chat +doesn't survive a lock: locking wipes it, as closing it would. + +### macOS asks for keychain access when I unlock + +**Problem.** Unlocking Loach brings up a macOS prompt asking to let it use +its `dev.loach.app` keychain item — often right after an update. + +**Solution.** Loach reads the lock from the keychain when you unlock (the +lock screen itself comes up without it), and macOS can ask again after +each update, since Loach isn't signed with an Apple Developer ID. Enter +your login password and choose **Always Allow**. If you deny it, Loach +can't check your PIN or password and stays locked — try again and allow +it. ### The lock fired later than the timeout I set @@ -663,7 +841,7 @@ past the interval locks the moment it wakes. --- -## 8. Models editor +## 9. Models editor ### "Save as new model" rejects my Modelfile @@ -679,8 +857,9 @@ directives. - The **SYSTEM** and **TEMPLATE** bodies cannot contain `"""`. If you need a triple-quote in your system prompt, rephrase. - **Save as new model** writes under the tag you type. If a model with that - tag already exists, it's replaced without asking — pick a tag that - isn't taken if you want to keep the old one. + tag already exists — or the tag is the base model's own — Loach asks + before replacing it; pick a tag that isn't taken if you want to keep the + old one. ### A model pull is stuck @@ -706,7 +885,7 @@ the tag matches what `ollama list` shows, then retry from the Models tab. --- -## 9. Updates +## 10. Updates ### No "Install update" button on Linux @@ -765,7 +944,7 @@ a suffix such as `-beta-1` or `-rc-1`) — download those from GitHub. --- -## 10. Data, import, and export +## 11. Data, import, and export ### Import won't accept my JSON file @@ -776,6 +955,11 @@ by **Settings → Data → Export data**. The per-chat **Export context** Markdown is not an import source; paste it into another chat with **Import context** instead. +An "unsupported export schema 'loach/v2'" error means the backup was made +by Loach 1.6 or later and you're restoring it into 1.5 or older, which +can't hold the Library. Update Loach first; newer versions still restore +older backups. + ### "Remove my data" didn't remove my OpenAI key **Problem.** After erasing, your OpenAI key is still configured. @@ -788,7 +972,7 @@ API key. To clear everything, including the key and the app lock, use --- -## 11. Appearance and window +## 12. Appearance and window ### Aurora theme tears or stutters @@ -814,15 +998,16 @@ uniform. --- -## 12. Search palette +## 13. Search palette ### Cmd/Ctrl+K does nothing **Problem.** The shortcut doesn't open the global search. **Solution.** The palette is suppressed while the **onboarding wizard**, -the **lock screen** or **Private Chat** owns the window. Finish -onboarding, unlock the app or close Private Chat first. +the **lock screen** or **Private Chat** owns the window, and while another +dialog (Settings, a confirmation) is open. Finish onboarding, unlock the +app, close Private Chat or the dialog first. ### Search doesn't find a phrase I know I wrote @@ -854,9 +1039,20 @@ If titles crowd out what you want, narrow with the scope dropdown or type `in:messages` in the query — scoped to messages, transcript hits get the whole list instead of a reserved block at the bottom. +### I can't find a Library item + +**Problem.** The Library page has no search box, and the palette doesn't +list an item you know is there. + +**Solution.** The Library is searched from the palette (**Cmd/Ctrl+K**); +type `in:library` or pick *library* in the scope dropdown to see only +Library items and datasets. Items match by title, the opening of their +text (about 280 characters) and a link's address — not by words deeper in +the body. Datasets match by name and description. + --- -## 13. Platform +## 14. Platform ### macOS: "Loach is damaged and can't be opened" on first launch diff --git a/docs/images/loach-demo.webp b/docs/images/loach-demo.webp new file mode 100644 index 0000000..7ae4939 Binary files /dev/null and b/docs/images/loach-demo.webp differ diff --git a/docs/images/loach-ui.png b/docs/images/loach-ui.png deleted file mode 100644 index 98900c5..0000000 Binary files a/docs/images/loach-ui.png and /dev/null differ diff --git a/index.html b/index.html index e8a9393..dd31903 100644 --- a/index.html +++ b/index.html @@ -4,20 +4,14 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Loach - - +
diff --git a/package-lock.json b/package-lock.json index 639c41a..c4b419c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "loach", - "version": "1.3.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loach", - "version": "1.3.0", + "version": "1.5.0", "license": "MIT", "dependencies": { "@radix-ui/react-dialog": "^1.1.23", @@ -18,19 +18,19 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.3.7", "@radix-ui/react-tabs": "^1.1.21", - "@tauri-apps/api": "^2.11.1", - "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-shell": "^2.3.5", - "@tauri-apps/plugin-updater": "^2.10.1", + "@tauri-apps/api": "^2.12.0", + "@tauri-apps/plugin-process": "^2.4.0", + "@tauri-apps/plugin-shell": "^2.4.0", + "@tauri-apps/plugin-updater": "^2.13.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "highlight.js": "^11.12.0", - "katex": "^0.16.47", - "lucide-react": "^1.33.0", - "mammoth": "^1.12.1", - "pdfjs-dist": "^6.2.108", - "react": "^19.2.8", - "react-dom": "^19.2.8", + "katex": "^0.18.11", + "lucide-react": "^1.49.0", + "mammoth": "^1.13.0", + "pdfjs-dist": "^6.3.289", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", @@ -42,17 +42,18 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.20", - "@tauri-apps/cli": "^2.11.4", - "@types/node": "^22.20.1", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^6.1.0", - "autoprefixer": "^10.5.4", - "postcss": "^8.5.26", + "@tauri-apps/cli": "^2.12.0", + "@types/node": "^22.20.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "autoprefixer": "^10.6.1", + "oxlint": "^1.88.0", + "postcss": "^8.5.28", "tailwindcss": "^3.4.19", "typescript": "^7.0.2", - "vite": "^8.2.2", - "vitest": "^4.1.11" + "vite": "^8.3.4", + "vitest": "^5.0.3" } }, "node_modules/@alloc/quick-lru": { @@ -125,9 +126,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -426,13 +427,336 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.146.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", - "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.88.0.tgz", + "integrity": "sha512-k6Fgkf86ubfeH4GvqYsRolfuVAp0pfYVZvifSlXNHPdMaWUOfxUrmLEeDD7hF6U2oJuhsVxhSWq86/xCJ+9MmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.88.0.tgz", + "integrity": "sha512-AsIyhl9o1ejwtac7NMbhKpMdSml6NWR0ljKjZERxC8OHYDAW8o/Om9n1QHVLi4VCEUo42Zte2/Op9kyoGpJZMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.88.0.tgz", + "integrity": "sha512-qgBjB7eEKcCB4RqjXGuoSZq74g0wD09fWFJ+HWcCJcKaga8TeUHtb+BY0SsxdleSLBgx4RZ7qmgo0EzqEq7JoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.88.0.tgz", + "integrity": "sha512-sCz3X5Mw/xdzEzn4fsCt1Iq8NKJnGNfhR+b9B5k+4DysYAHsmIhYoocnBPiQ9kvY3+pUun2A0Z8Vtun/Nv6YoQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.88.0.tgz", + "integrity": "sha512-xTOdYOIqCufgIj0knvxu86FBfovP6QBiD7ngNva01gb8xPQCD5gXkE5APGczH35zStim1O3zqvFbLQmY85xXRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.88.0.tgz", + "integrity": "sha512-BLadtw88NdYJBrAtC1dYzrPfSP2d5zgx6iuZycdD0YmZBJ2Nqbit9Z4PSaIb6gxK25dweArGMPbOmq/0ugwUPg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.88.0.tgz", + "integrity": "sha512-wILSczl/DaZsCcf7j7tvNbssp8hxEHIJxrv4j7+JjI3TAL1U/JTWbeUtoJJC/vo8+2hkTmHLYoKJjmiNbijdXw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.88.0.tgz", + "integrity": "sha512-jutT2Hy8JCuZuMwIFHo2HtyxfOfBJHyQxlBHBK1SL9W2f8n/WhjAP0FnDMz9d/mjhv9+QA0mx5AdQ9fDjsrFFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.88.0.tgz", + "integrity": "sha512-8N8iGV9hh4Lkeuq7QsEkjoXNRRflqR1G2qyoGYXcA0k5qllrdqmfylKEXyHIVERxqNDyMO4StqDQ0uLxXcBQAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.88.0.tgz", + "integrity": "sha512-5ydcQt3znNQ/rGEWYRvpS2cK9+T18P2VYgnyJ+eloEAwi9YUmsXy5VGrRqNWIK6qzWwHGnEE3ojVqqQNo+f0hA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.88.0.tgz", + "integrity": "sha512-iGx0uvFT54b/5DPJdEBCxYIrVB3v0uTy+JhRh3CyEp8/22BXDkG8rffkFwREdLsdj9mOrPbJYVOZw8ipcBuzjw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.88.0.tgz", + "integrity": "sha512-Wt47mKMWelVjRjuR7BCFxXkB0m79K1sTEbqMHRWciKUgo5iv7sWsF4RXSOyFYHD9zerNrkRZblnN273rhwOA1Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.88.0.tgz", + "integrity": "sha512-g3L12KCS4+1gtUt3RbJ4F+Yxx4LNmmZLEPfhd8rAHEJjJk2lAgNfnjGoP/phJyhG5WS6VbNYbJtadJelNfvTMA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.88.0.tgz", + "integrity": "sha512-og/XHVDOD9ZJgRiZkT9DgzQd4sr+JZW7e4l17Q/jVMZiIbeEd4uIu9iAoUbTF1gIvVZoRc4QDOxyVUvQGp4WHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.88.0.tgz", + "integrity": "sha512-EmhBsp+xuKb6YbZNEMLBo80Xw71AZOIm18do8ni/GLX/Lw1XzNhVYpbxO1KasYtoAvrGtCOdCPULRlIE0bPbTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.88.0.tgz", + "integrity": "sha512-DBUcx/KwYdg1DAz+G01P/GFv512J04EsZadbHptx2FTpWT/MWRg21ZToNHt9pnsfQRBQ3et/g+vqzbvrdAVbuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.88.0.tgz", + "integrity": "sha512-xeVd0n4XG5HvynxfYvXi6uB+JvZbVZataowPS8cYpMbYg9mwE4sSyElFQd4LVJFifq8bRLDSR18XgEildmcXAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.88.0.tgz", + "integrity": "sha512-MA6iOw//amleaz2mVMsZrA7NHlEexjTrhIxSMFCXQi6IUuQO+3Od00GZhr2iCYw6RFzMW36jkKK/WHBXMsoQAw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.88.0.tgz", + "integrity": "sha512-3Sz6rVWe8Dy+ufsXurFN1GX2dX43F/lQMSLelK9gFjiba7nHvLlqlhR5cHlWVnCpSqUY1hN9pyCX9XToe6ve6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@radix-ui/number": { @@ -1194,9 +1518,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", - "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", "cpu": [ "arm" ], @@ -1211,9 +1535,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", - "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", "cpu": [ "arm64" ], @@ -1228,9 +1552,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", - "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", "cpu": [ "arm64" ], @@ -1245,9 +1569,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", - "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", "cpu": [ "x64" ], @@ -1262,9 +1586,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", - "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", "cpu": [ "x64" ], @@ -1279,9 +1603,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", - "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", "cpu": [ "arm" ], @@ -1296,9 +1620,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", - "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", "cpu": [ "arm64" ], @@ -1313,9 +1637,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", - "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", "cpu": [ "arm64" ], @@ -1330,9 +1654,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", - "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", "cpu": [ "ppc64" ], @@ -1347,9 +1671,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", - "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", "cpu": [ "s390x" ], @@ -1364,9 +1688,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", - "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", "cpu": [ "x64" ], @@ -1381,9 +1705,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", - "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", "cpu": [ "x64" ], @@ -1398,9 +1722,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", - "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", "cpu": [ "arm64" ], @@ -1415,9 +1739,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", - "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", "cpu": [ "arm64" ], @@ -1432,9 +1756,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", - "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", "cpu": [ "x64" ], @@ -1455,13 +1779,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, "node_modules/@tailwindcss/typography": { "version": "0.5.20", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", @@ -1476,9 +1793,9 @@ } }, "node_modules/@tauri-apps/api": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", - "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.0.tgz", + "integrity": "sha512-fUSxFX8VABYe9W9K5ROdEaKVkNUFRcMSRofjruvaxztXA+I0XnQHcYHF8m/a6iExRUxsUf0HsFWhPBuH9IgzJw==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -1486,9 +1803,9 @@ } }, "node_modules/@tauri-apps/cli": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", - "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.0.tgz", + "integrity": "sha512-GIShnAR4C16kSTA8NO02fo8fOw/I5z5sAUEB5S2HlPHFnBkbihL4c9a/MpkxOm1+q5S+tFd3X4QUjHWxfyGzpQ==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -1502,23 +1819,23 @@ "url": "https://opencollective.com/tauri" }, "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" + "@tauri-apps/cli-darwin-arm64": "2.12.0", + "@tauri-apps/cli-darwin-x64": "2.12.0", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.0", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.0", + "@tauri-apps/cli-linux-arm64-musl": "2.12.0", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.0", + "@tauri-apps/cli-linux-x64-gnu": "2.12.0", + "@tauri-apps/cli-linux-x64-musl": "2.12.0", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.0", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.0", + "@tauri-apps/cli-win32-x64-msvc": "2.12.0" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", - "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.0.tgz", + "integrity": "sha512-v5J+jISihLGtih1+3yWagbsL+5dbLZORu4vaSI0vhY0Heb+MAW+T/1s1RyKxrQrwEPGBG5kqwfDDTnSbmVkuqA==", "cpu": [ "arm64" ], @@ -1533,9 +1850,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", - "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.0.tgz", + "integrity": "sha512-g27I07pfW/dMS8vcNqB9lamgKF0TqLlcy8e7t9qDGsB1iaeyjJWb2TgBF4tM9ZwLqPAZUXbsz28oXljO12WC4g==", "cpu": [ "x64" ], @@ -1550,9 +1867,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", - "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.0.tgz", + "integrity": "sha512-gXyQ710fNc99YuxFxNyeX9PLSrIpbtgBBj7GQo9tSeRT6fIK40q3/gm7X5jqZo7mWLR2m0R/2ALd3vAWo0Z32Q==", "cpu": [ "arm" ], @@ -1567,9 +1884,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", - "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.0.tgz", + "integrity": "sha512-H9cyXuq9WgxoHh2NZ+R+oN+W78x5UwaBDEp0/fmqUUkaBoioCuOZErM9xKiIV6bpNAhmw1RoWZ49u37q/vaiAg==", "cpu": [ "arm64" ], @@ -1584,9 +1901,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", - "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.0.tgz", + "integrity": "sha512-5tFuk3JKMBa1M/iN4gN32OYlSfhY3aqb5PO3zCugxaT+sfecyMKpc3EM9VDbhmwWBE9QD4Tfj7wGhlqx2yy2pQ==", "cpu": [ "arm64" ], @@ -1601,9 +1918,9 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", - "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.0.tgz", + "integrity": "sha512-LOrMyV+nb7pKq8P0BZtTBQuyzm1ORjn16R9MlupmxuT5LqxtuYJlw3VMwbaeXCToJ8jbP5u9JnkfmbqDlvETXw==", "cpu": [ "riscv64" ], @@ -1618,9 +1935,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", - "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.0.tgz", + "integrity": "sha512-aMIkI+X9qvD2+k80QZB3tuOYT41/cEheikC510CwAIZ31KLXORsRASYYzM4mi1/58ICmtzSLFhCC08keir7COw==", "cpu": [ "x64" ], @@ -1635,9 +1952,9 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", - "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.0.tgz", + "integrity": "sha512-9gl3NhUbfuGhSKjeGCypKSdtoezOQRcDUrkhTWtLQEjnajets6peO6NadgI2DF0OzbiiFOzrnnIGMwBS+Ap6Iw==", "cpu": [ "x64" ], @@ -1652,9 +1969,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", - "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.0.tgz", + "integrity": "sha512-1W+v86wsYLuyBDN0iDessD4CD9o+yNyO0m8Dp3+/vfjiZrJ/v7R7/aTRBeikaq2tLGgxeag0gxnFggBNBGw91A==", "cpu": [ "arm64" ], @@ -1669,9 +1986,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", - "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.0.tgz", + "integrity": "sha512-SsBjvq4f7YaBGqcWoE3cycoItPf0SrQeqFqWWgfxoeZWAIz01E31LDSeZ2p/etJiIY0R9oi045chkFfb0O7WGw==", "cpu": [ "ia32" ], @@ -1686,9 +2003,9 @@ } }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", - "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.0.tgz", + "integrity": "sha512-9zzDKYUH4TKKBgRdDdVQX8fiRO5gJ8hD1nGsHU9DOHG5Jpm3arjbu9Xg1frR20EusG3Sl8X0dppoO/4EoUXUGg==", "cpu": [ "x64" ], @@ -1703,30 +2020,30 @@ } }, "node_modules/@tauri-apps/plugin-process": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz", - "integrity": "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.4.0.tgz", + "integrity": "sha512-GddrmsSLAYrLa/yTXlXK/YRDzUgYeeOnUXTbiyvJWkmI424RmDPD3FVLeqcon7rv6O6ZfhBilcrHWnSo7o6hIQ==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.8.0" + "@tauri-apps/api": "^2.12.0" } }, "node_modules/@tauri-apps/plugin-shell": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", - "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.4.0.tgz", + "integrity": "sha512-kto+mIv8reX1iryIDdjfsdv3l3BtMN0tvQ6dhs3uMO1MgjvdHsD9zql4cBE6W44qnRw/GMtsiThWsgcQASd+ig==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.10.1" + "@tauri-apps/api": "^2.12.0" } }, "node_modules/@tauri-apps/plugin-updater": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", - "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.13.1.tgz", + "integrity": "sha512-+STDzJ0sdQLImjc63zkv6Nncimqeo6jyCWoTQtbSwgGLNj1qbP9I6LC4p5R5ocBk3gNKAZs6i/AJz0jxysytiw==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.10.1" + "@tauri-apps/api": "^2.12.0" } }, "node_modules/@types/chai": { @@ -1802,9 +2119,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", "dev": true, "license": "MIT", "dependencies": { @@ -1812,22 +2129,22 @@ } }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "devOptional": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/unist": { @@ -2183,9 +2500,9 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", - "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2212,34 +2529,17 @@ } } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2257,74 +2557,16 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", "dev": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@xmldom/xmldom": { "version": "0.8.15", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", @@ -2391,9 +2633,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.4", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", - "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", + "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==", "dev": true, "funding": [ { @@ -2411,8 +2653,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.6", - "caniuse-lite": "^1.0.30001806", + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -2458,9 +2700,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", - "integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", + "version": "2.11.26", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", + "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2482,12 +2724,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bluebird": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", - "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", - "license": "MIT" - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2501,9 +2737,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -2521,11 +2757,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -2544,9 +2780,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001809", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", - "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", "dev": true, "funding": [ { @@ -2700,13 +2936,6 @@ "node": ">= 6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -2827,9 +3056,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.413", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.413.tgz", - "integrity": "sha512-F1XPKvt7HVfly5WND90ec16nFsdr4g5x/cVUP3EqjeyXynupabGDqpMa84wwvuYGDnldXLBz6DLXyZXWO9TPvw==", + "version": "1.5.442", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.442.tgz", + "integrity": "sha512-najZYZ3+ZpjN1z3VOsrBiv5ej18vQgfV2lvEmxWzfmKrk4bdm3Owseyud4yGU6DfFn9pyunoAHDDyM4KmP78Ew==", "dev": true, "license": "ISC" }, @@ -3384,28 +3613,29 @@ } }, "node_modules/katex": { - "version": "0.16.47", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "version": "0.18.11", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz", + "integrity": "sha512-yvyz/2VMKqoho/35Hat5w37wIEdWJ5ElVMidTAlWrxr5uW4aBGsEpveTmKh18/zw08v/7lGswCPon4OhqmqrTA==", + "deprecated": "Accidentally published with breaking changes. Use 0.19.0 instead.", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], "license": "MIT", "dependencies": { - "commander": "^8.3.0" + "commander": "^15.0.0" }, "bin": { "katex": "cli.js" } }, "node_modules/katex/node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "license": "MIT", "engines": { - "node": ">= 12" + "node": ">=22.12.0" } }, "node_modules/lie": { @@ -3742,38 +3972,42 @@ } }, "node_modules/lucide-react": { - "version": "1.34.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.34.0.tgz", - "integrity": "sha512-vnjGJNI7Htk5+oWW8gXGuaLgwgAb0T6/iZbBrp9JCfRFwdNWZ0YTm3eyxjOLgwN6r8iyAf3UA70zNmBRBNv7yg==", + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.49.0.tgz", + "integrity": "sha512-ApfaNM4/7jBrVopqLJ84Jz8TMQ1Q1JeZ1PmKh25aeS3E1FTVcMkSh1bEQB7dfzdIrwXRP8p3z4AJjTIHHb5IIA==", "license": "ISC", "peerDependencies": { + "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/mammoth": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.1.tgz", - "integrity": "sha512-nCH9KKjWi3jQ+i8bUKs7k1yrXtSEGpWgF8IYkzsFMcbn+5S6l4bZEBbyx2hOQErFiXPuAs9RPa6qjXVxhyx/8g==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.13.0.tgz", + "integrity": "sha512-2cawcYFcP7ISfXbBcL9lmVgTNWo88v/52ND1gnywHtVT/pX0//puPnf0iMPV9ZgmuvNOE+zkIhDgZ9M2c2dDDg==", "license": "BSD-2-Clause", "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", - "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", - "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, @@ -4705,9 +4939,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -4723,9 +4957,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.53", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", - "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, "license": "MIT", "engines": { @@ -4779,6 +5013,55 @@ "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", "license": "BSD-2-Clause" }, + "node_modules/oxlint": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.88.0.tgz", + "integrity": "sha512-uIcFgw8BmqvFRGzH5+r7d2zJMEexwNSrEmM6WQNnGh3VkO0XYTEFoyxyNE6tjKMUib9Cue4XJA2DoWruO5D6aw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.88.0", + "@oxlint/binding-android-arm64": "1.88.0", + "@oxlint/binding-darwin-arm64": "1.88.0", + "@oxlint/binding-darwin-x64": "1.88.0", + "@oxlint/binding-freebsd-x64": "1.88.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.88.0", + "@oxlint/binding-linux-arm-musleabihf": "1.88.0", + "@oxlint/binding-linux-arm64-gnu": "1.88.0", + "@oxlint/binding-linux-arm64-musl": "1.88.0", + "@oxlint/binding-linux-ppc64-gnu": "1.88.0", + "@oxlint/binding-linux-riscv64-gnu": "1.88.0", + "@oxlint/binding-linux-riscv64-musl": "1.88.0", + "@oxlint/binding-linux-s390x-gnu": "1.88.0", + "@oxlint/binding-linux-x64-gnu": "1.88.0", + "@oxlint/binding-linux-x64-musl": "1.88.0", + "@oxlint/binding-openharmony-arm64": "1.88.0", + "@oxlint/binding-win32-arm64-msvc": "1.88.0", + "@oxlint/binding-win32-ia32-msvc": "1.88.0", + "@oxlint/binding-win32-x64-msvc": "1.88.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -4822,32 +5105,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/pdfjs-dist": { - "version": "6.2.108", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz", - "integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==", + "version": "6.3.289", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz", + "integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==", "license": "Apache-2.0", "engines": { "node": ">=22.13.0 || >=24" @@ -4884,9 +5151,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "funding": [ { "type": "opencollective", @@ -4903,9 +5170,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -5020,24 +5287,10 @@ "postcss": "^8.2.14" } }, - "node_modules/postcss-nested/node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dev": true, + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz", + "integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -5090,24 +5343,24 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.8" + "react": "^19.3.0" } }, "node_modules/react-markdown": { @@ -5389,13 +5642,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", - "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.146.0", + "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5405,21 +5658,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.5", - "@rolldown/binding-android-arm64": "1.2.5", - "@rolldown/binding-darwin-arm64": "1.2.5", - "@rolldown/binding-darwin-x64": "1.2.5", - "@rolldown/binding-freebsd-x64": "1.2.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", - "@rolldown/binding-linux-arm64-gnu": "1.2.5", - "@rolldown/binding-linux-arm64-musl": "1.2.5", - "@rolldown/binding-linux-ppc64-gnu": "1.2.5", - "@rolldown/binding-linux-s390x-gnu": "1.2.5", - "@rolldown/binding-linux-x64-gnu": "1.2.5", - "@rolldown/binding-linux-x64-musl": "1.2.5", - "@rolldown/binding-openharmony-arm64": "1.2.5", - "@rolldown/binding-win32-arm64-msvc": "1.2.5", - "@rolldown/binding-win32-x64-msvc": "1.2.5" + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" } }, "node_modules/run-parallel": { @@ -5452,9 +5705,9 @@ "license": "MIT" }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/setimmediate": { @@ -5463,17 +5716,10 @@ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "license": "MIT" }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -5495,13 +5741,6 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -5640,19 +5879,6 @@ "tailwindcss": ">=3.0.0 || insiders" } }, - "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -5675,11 +5901,14 @@ } }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { "version": "1.3.0", @@ -5736,16 +5965,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5954,9 +6173,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", - "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -6076,16 +6295,16 @@ } }, "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -6102,7 +6321,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -6154,9 +6373,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -6167,38 +6386,31 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -6206,16 +6418,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -6257,9 +6469,9 @@ } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -6280,20 +6492,16 @@ } }, "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", "dev": true, "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, "bin": { "why-is-node-running": "cli.js" }, "engines": { - "node": ">=8" + "node": ">=20.11" } }, "node_modules/xmlbuilder": { diff --git a/package.json b/package.json index 7313234..4ca91ba 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "loach", "private": true, - "version": "1.5.0", + "version": "1.6.0", "type": "module", "description": "Native local LLM chat client for Windows, Linux and macOS (Ollama + OpenAI-compatible).", "license": "MIT", @@ -12,7 +12,8 @@ "test": "vitest run", "tauri": "tauri", "tauri:dev": "tauri dev", - "tauri:build": "tauri build" + "tauri:build": "tauri build", + "lint": "oxlint -c .oxlintrc.json src tests" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.23", @@ -24,19 +25,19 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.3.7", "@radix-ui/react-tabs": "^1.1.21", - "@tauri-apps/api": "^2.11.1", - "@tauri-apps/plugin-process": "^2.3.1", - "@tauri-apps/plugin-shell": "^2.3.5", - "@tauri-apps/plugin-updater": "^2.10.1", + "@tauri-apps/api": "^2.12.0", + "@tauri-apps/plugin-process": "^2.4.0", + "@tauri-apps/plugin-shell": "^2.4.0", + "@tauri-apps/plugin-updater": "^2.13.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "highlight.js": "^11.12.0", - "katex": "^0.16.47", - "lucide-react": "^1.33.0", - "mammoth": "^1.12.1", - "pdfjs-dist": "^6.2.108", - "react": "^19.2.8", - "react-dom": "^19.2.8", + "katex": "^0.18.11", + "lucide-react": "^1.49.0", + "mammoth": "^1.13.0", + "pdfjs-dist": "^6.3.289", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", @@ -48,19 +49,23 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.20", - "@tauri-apps/cli": "^2.11.4", - "@types/node": "^22.20.1", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^6.1.0", - "autoprefixer": "^10.5.4", - "postcss": "^8.5.26", + "@tauri-apps/cli": "^2.12.0", + "@types/node": "^22.20.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "autoprefixer": "^10.6.1", + "oxlint": "^1.88.0", + "postcss": "^8.5.28", "tailwindcss": "^3.4.19", "typescript": "^7.0.2", - "vite": "^8.2.2", - "vitest": "^4.1.11" + "vite": "^8.3.4", + "vitest": "^5.0.3" }, "overrides": { - "@xmldom/xmldom": "^0.8.13" + "@xmldom/xmldom": "^0.8.13", + "katex": "$katex", + "postcss-selector-parser": "^6.1.5", + "source-map-js": "^1.2.2" } } diff --git a/releases/1.6.0.md b/releases/1.6.0.md new file mode 100644 index 0000000..e8326ce --- /dev/null +++ b/releases/1.6.0.md @@ -0,0 +1,24 @@ +**What's new** +- **Library.** A new sidebar page for the texts, files (PDF, Word, code, images) and web links you give your models, saved once and used anywhere. Group them into datasets, then attach an item or a whole dataset to a chat (**+ → From Library**, or type `#`) or to a Space. Sources are linked, not copied, so an edit reaches every chat on its next message. A reply, or a message's attachments, can be saved to the Library from its menu. +- **Sources sized to the context window.** Small sources go to the model in full. Once they outgrow half the window, only the passages most relevant to each message are sent. Choose **Auto**, **Full text** or **Excerpts** from the **Sources** chip, and every reply shows which sources it used. Search works by keyword out of the box, and by meaning once you add an embedding model in **Settings → Library**: one click downloads one for Ollama, which keeps everything on your machine, or use your OpenAI-compatible endpoint. +- **Spaces use the Library too.** A Space's **Sources** tab takes Library items and datasets alongside uploads, shows how much of the window they take, and lets you pause any one of them. A new **Settings** tab brings together the Space's default model, memory auto-save and source mode. Spaces made before 1.6 keep sending their sources in full until you switch them to Auto. +- **HTML preview.** HTML code blocks and `.html` attachments get a **Preview** in the code canvas. The page renders offline in a sandbox with scripts off, and **Run scripts** turns them on for that page. +- **Image scaling.** Images are now scaled down before they're sent, to 1024 px on the longest side by default. A full-size photo costs thousands of tokens, and many local models read it worse, not better. The original stays in your chat. Change it or turn it off in **Settings → Features**, for Library images in **Settings → Library**, or per Space and per chat. +- **More slash commands.** `/stop`, `/find`, `/import`, `/label`, `/folder`, `/tone`, `/set`, `/unload`, `/theme` and `/lock` join the palette, which now completes model, Space, snippet and persona names. Bare `/model` opens the model picker. +- **Drafts stay with their chat.** Unsent text and attached files belong to the chat (or Space page) you wrote them in, so switching chats no longer carries them along. +- **At home on macOS.** Native window buttons, a menu bar icon that shows the fish, Cmd-only shortcuts, and a Dock click that reopens the window. Closing the window hides Loach instead of quitting it, and locks it when an app lock is set. +- **Smaller touches.** Launching Loach while it's running brings the open window forward instead of starting a second copy. Search (**Cmd/Ctrl+K**) covers the Library, with `in:library` to narrow it. The model picker lists every model an OpenAI-compatible endpoint offers. + +**Fixes** +- **OpenAI reasoning models work.** newer GPT models that refuse `max_tokens` or a custom temperature are retried with the fields they accept. +- **Steadier app lock.** The lock screen at launch no longer reads the system keychain, so there's no keychain prompt before you've typed anything. Failed unlock attempts survive a restart, and locking closes open dialogs and ends a Private Chat. +- **Private Chat stays private.** It no longer offers built-in tools or Ollama cloud models, and it tells you when Ollama runs on another machine. +- **Safer workspace edits.** Changes to git hook folders and bare repositories always ask first, paths using Windows short names (`IMPORT~1.MD`) are refused, and an edited file keeps its Windows permissions. +- **Sturdier MCP.** A long tool call that reports progress is no longer cut off after 30 seconds, a hung server restarts on the next call, and tokens in a server's arguments or URL are left out of backups. +- **Keyboards and text files.** Shortcuts work on Cyrillic and Greek layouts, `Cmd/Ctrl + /` works where `/` needs Shift, and UTF-16 and Windows-1250/1252 text files open correctly as attachments. +- **Closer counts.** The context bar includes a Space's instructions and sources and estimates Chinese, Japanese and Korean text properly. The `count` tool treats an emoji or accented letter as one character. +- **The Models editor asks** before saving over an existing tag, the base model included. +- Many smaller fixes and hardening throughout, plus dependency updates including Tauri 2.12. + +**Good to know** +- Backups made by 1.6.0 include the Library, so Loach 1.5 and older refuse to restore them. 1.6.0 still restores older backups. diff --git a/releases/README.md b/releases/README.md index f6541eb..c4318f0 100644 --- a/releases/README.md +++ b/releases/README.md @@ -8,7 +8,7 @@ the merged version and uses its content as **both**: - the GitHub release body, and - the `notes` field inside `latest.json` (what users see in-app under - Settings → Updates → "What's new"). + Settings → Updates → "Release notes"). Keeping them in sync means there's no post-publish edit step. @@ -28,9 +28,14 @@ The macOS install / Gatekeeper-bypass instructions live in the top-level don't trigger anything. 4. When the build finishes, inspect the **draft** release on GitHub. Open `latest.json` from the assets and check every platform entry points at the - right artifact, each with a non-empty base64 `signature`: + right artifact, each with a non-empty base64 `signature`. The `url`s are + GitHub API asset links (`api.github.com/.../releases/assets/`) that + don't name the file, so read the artifact from the signature instead: + base64-decode it (PowerShell: + `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($sig))`) and + check the `file:` field on its `trusted comment:` line: - | key | artifact | + | key | `file:` | | --- | --- | | `windows-x86_64`, `windows-x86_64-nsis` | `*-setup.exe` | | `linux-x86_64`, `linux-x86_64-appimage` | `*.AppImage` | @@ -42,7 +47,8 @@ The macOS install / Gatekeeper-bypass instructions live in the top-level `.rpm`: installs running updater plugin < 2.10 only look up that key, and handing them a package file breaks their in-place update. The `.dmg` uploads for manual install only — the updater can't patch it, so it never - appears in `latest.json`. (v1.2.3 is the reference shape to diff against.) + appears in `latest.json`. (v1.2.3 is the reference set of keys to diff + against.) 5. Click **Publish release**. The in-app updater picks it up on next check — unless the version has a pre-release suffix (`-beta-1`, `-rc-1`): those publish as GitHub pre-releases, which the updater's `releases/latest` diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 98d3a28..0595f72 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -10,24 +10,13 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aes" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cipher 0.5.2", + "cipher", "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -51,13 +40,28 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + [[package]] name = "alloc-stdlib" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ - "alloc-no-stdlib", + "alloc-no-stdlib 2.0.4", +] + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib 3.0.0", ] [[package]] @@ -66,8 +70,8 @@ version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab097a7be305dd66b2b6917f5efb6b723129ce4b2cd11aa1a0afcc293c7dc08e" dependencies = [ - "bitflags 2.13.1", - "brotli-decompressor", + "bitflags 2.13.2", + "brotli-decompressor 5.0.3", "encoding_rs", "enumflags2", "flate2", @@ -120,13 +124,13 @@ dependencies = [ [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures 0.3.1", "password-hash", ] @@ -156,9 +160,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.43" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" dependencies = [ "compression-codecs", "compression-core", @@ -256,28 +260,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "async-task" version = "4.7.1" @@ -292,7 +274,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -377,20 +359,20 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] @@ -411,15 +393,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - [[package]] name = "block-padding" version = "0.4.2" @@ -453,13 +426,13 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.4" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", + "brotli-decompressor 6.0.1", ] [[package]] @@ -468,8 +441,18 @@ version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "alloc-no-stdlib 2.0.4", + "alloc-stdlib 0.2.4", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib 3.0.0", + "alloc-stdlib 0.3.0", ] [[package]] @@ -509,12 +492,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - [[package]] name = "bytes" version = "1.12.1" @@ -530,7 +507,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cairo-sys-rs", "glib", "libc", @@ -551,9 +528,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.5" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ "serde_core", ] @@ -578,26 +555,18 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "cargo_toml" -version = "0.22.3" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" dependencies = [ + "semver", "serde", - "toml 0.9.12+spec-1.1.0", -] - -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher 0.4.4", + "toml 1.1.6+spec-1.1.0", ] [[package]] @@ -606,14 +575,14 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ - "cipher 0.5.2", + "cipher", ] [[package]] name = "cc" -version = "1.4.4" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "shlex", @@ -627,13 +596,13 @@ checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] name = "cfb" -version = "0.7.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" dependencies = [ - "byteorder", "fnv", "uuid", + "web-time", ] [[package]] @@ -648,9 +617,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -660,13 +629,13 @@ checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "cpufeatures 0.3.1", + "rand_core", ] [[package]] @@ -691,7 +660,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -704,16 +673,6 @@ dependencies = [ "phf 0.12.1", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout 0.1.4", -] - [[package]] name = "cipher" version = "0.5.2" @@ -721,14 +680,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "crypto-common 0.2.2", - "inout 0.2.2", + "inout", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -736,9 +701,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.38" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" dependencies = [ "brotli", "compression-core", @@ -748,9 +713,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" [[package]] name = "concurrent-queue" @@ -777,6 +742,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -799,8 +774,8 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.13.1", - "core-foundation", + "bitflags 2.13.2", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -812,11 +787,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.13.1", - "core-foundation", + "bitflags 2.13.2", + "core-foundation 0.10.1", "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpubits" version = "0.1.1" @@ -834,36 +815,36 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-channel" -version = "0.5.16" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crypto-common" @@ -886,9 +867,9 @@ dependencies = [ [[package]] name = "cssparser" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" dependencies = [ "cssparser-macros", "dtoa-short", @@ -899,35 +880,34 @@ dependencies = [ [[package]] name = "cssparser-macros" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] name = "ctor" -version = "0.8.0" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" -dependencies = [ - "ctor-proc-macro", - "dtor", -] +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] -name = "ctor-proc-macro" -version = "0.0.7" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -935,26 +915,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1010,7 +990,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -1062,7 +1042,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", - "subtle", ] [[package]] @@ -1074,13 +1053,14 @@ dependencies = [ "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", + "ctutils", ] [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys", ] @@ -1094,7 +1074,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1103,7 +1083,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -1117,7 +1097,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -1145,9 +1125,9 @@ dependencies = [ [[package]] name = "dom_query" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" dependencies = [ "bit-set", "cssparser", @@ -1182,21 +1162,6 @@ dependencies = [ "dtoa", ] -[[package]] -name = "dtor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -1215,7 +1180,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac" dependencies = [ - "cipher 0.5.2", + "cipher", ] [[package]] @@ -1227,7 +1192,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "vswhom", "winreg", ] @@ -1240,11 +1205,16 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -1298,7 +1268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1380,18 +1350,19 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -1424,7 +1395,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -1495,7 +1466,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -1670,7 +1641,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", + "rand_core", "wasm-bindgen", ] @@ -1712,7 +1683,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "futures-channel", "futures-core", "futures-executor", @@ -1830,9 +1801,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -1840,7 +1811,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.14.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -1879,9 +1850,9 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" dependencies = [ "hashbrown 0.17.1", ] @@ -1900,9 +1871,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -1912,27 +1883,27 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] name = "html5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" dependencies = [ "log", "markup5ever", @@ -1979,18 +1950,18 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -2009,9 +1980,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -2025,25 +1996,28 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2058,7 +2032,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -2190,19 +2164,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", - "png 0.18.1", -] - [[package]] name = "indexmap" version = "1.9.3" @@ -2216,9 +2177,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -2228,38 +2189,28 @@ dependencies = [ [[package]] name = "infer" -version = "0.19.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" dependencies = [ "cfb", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding 0.3.3", - "generic-array", -] - [[package]] name = "inout" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "block-padding 0.4.2", + "block-padding", "hybrid-array", ] [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is-docker" @@ -2311,9 +2262,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" dependencies = [ "defmt", "jiff-core", @@ -2323,23 +2274,24 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-link 0.2.1", + "windows-link", ] [[package]] name = "jiff-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" dependencies = [ "defmt", + "log", ] [[package]] name = "jiff-static" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" dependencies = [ "jiff-core", "proc-macro2", @@ -2378,6 +2330,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.21", + "walkdir", + "windows-link", +] + +[[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.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2408,9 +2390,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -2419,21 +2401,21 @@ dependencies = [ [[package]] name = "json-patch" -version = "3.0.1" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.21", ] [[package]] name = "jsonptr" -version = "0.6.3" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" dependencies = [ "serde", "serde_json", @@ -2441,20 +2423,19 @@ dependencies = [ [[package]] name = "keyboard-types" -version = "0.7.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "serde", - "unicode-segmentation", ] [[package]] name = "keyring" -version = "4.1.6" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72585bb6cc9bc370d1d545b7e23fcce71dfd4461c5e15275e3cf51bdfd9a980a" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" dependencies = [ "apple-native-keyring-store", "keyring-core", @@ -2528,9 +2509,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] @@ -2560,11 +2541,10 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "loach" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "argon2", - "async-stream", "base64 0.23.1", "chardetng", "chrono", @@ -2575,8 +2555,10 @@ dependencies = [ "exmex", "futures-util", "glob", + "gtk", "ipnet", "keyring", + "keyring-core", "libc", "natord", "once_cell", @@ -2585,7 +2567,6 @@ dependencies = [ "printpdf", "r2d2", "r2d2_sqlite", - "rand_core 0.6.4", "regex", "reqwest 0.12.28", "rfd", @@ -2600,14 +2581,17 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-process", "tauri-plugin-shell", + "tauri-plugin-single-instance", "tauri-plugin-updater", "tempfile", "tokio", "tracing", "tracing-subscriber", + "unicode-segmentation", "uuid", "walkdir", - "windows 0.62.2", + "windows", + "zbus-secret-service-keyring-store", ] [[package]] @@ -2621,9 +2605,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lopdf" @@ -2631,14 +2615,14 @@ version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e2ec995d822e05cabc3f06d196ee43650af3fe4fe38012cacb35e0c3d113b68" dependencies = [ - "aes 0.9.2", - "bitflags 2.13.1", - "cbc 0.2.1", + "aes", + "bitflags 2.13.2", + "cbc", "ecb", "encoding_rs", "flate2", "getrandom 0.4.3", - "indexmap 2.14.0", + "indexmap 2.14.2", "itoa", "log", "md-5", @@ -2647,21 +2631,21 @@ dependencies = [ "rangemap", "sha2 0.11.0", "stringprep", - "thiserror 2.0.20", + "thiserror 2.0.21", "weezl", ] [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "markup5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ "log", "tendril", @@ -2725,31 +2709,31 @@ dependencies = [ ] [[package]] -name = "mio" -version = "1.2.2" +name = "miniz_oxide" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", + "adler2", + "simd-adler32", ] [[package]] -name = "moxcms" -version = "0.8.1" +name = "mio" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ - "num-traits", - "pxfm", + "libc", + "wasi", + "windows-sys 0.61.2", ] [[package]] name = "muda" -version = "0.19.3" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" dependencies = [ "crossbeam-channel", "dpi", @@ -2762,10 +2746,16 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.20", - "windows-sys 0.61.2", + "thiserror 2.0.21", + "windows-sys 0.60.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "natord" version = "1.0.9" @@ -2778,7 +2768,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys", @@ -2787,6 +2777,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -2826,7 +2822,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2945,11 +2941,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "objc2", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2958,7 +2955,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-foundation", ] @@ -2979,7 +2976,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", "objc2", ] @@ -2990,7 +2987,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", "objc2", "objc2-core-foundation", @@ -3023,7 +3020,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -3050,7 +3047,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -3073,7 +3070,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", ] @@ -3084,7 +3081,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-app-kit", "objc2-foundation", @@ -3096,7 +3093,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -3108,7 +3105,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "objc2", "objc2-cloud-kit", @@ -3139,7 +3136,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", "objc2", "objc2-app-kit", @@ -3155,9 +3152,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "5.4.1" +version = "5.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" dependencies = [ "dunce", "is-wsl", @@ -3193,7 +3190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3207,7 +3204,7 @@ dependencies = [ "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3285,18 +3282,17 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "getrandom 0.4.3", + "phc", ] [[package]] @@ -3324,6 +3320,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.12.1" @@ -3420,12 +3427,12 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plist" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ - "base64 0.22.1", - "indexmap 2.14.0", + "base64 0.23.1", + "indexmap 2.14.2", "quick-xml", "serde", "time", @@ -3441,7 +3448,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -3450,11 +3457,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -3479,9 +3486,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] @@ -3509,9 +3516,9 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "printpdf" -version = "0.12.6" +version = "0.12.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e75e1f6d16f80c8f34918a4cc68743d703e8ee5016fac9508e223eb8e60445a0" +checksum = "95d50369c5e18e18d4716bd855172db3a70f9e82c966687ae051ea58138f285c" dependencies = [ "allsorts-azul", "base64 0.22.1", @@ -3554,7 +3561,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", + "toml_edit 0.25.15+spec-1.1.0", ] [[package]] @@ -3603,26 +3610,20 @@ dependencies = [ "yansi", ] -[[package]] -name = "pxfm" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" - [[package]] name = "quick-xml" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", ] [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -3632,7 +3633,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -3640,9 +3641,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.17" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "bytes", "getrandom 0.4.3", @@ -3654,7 +3655,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -3662,16 +3663,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3719,22 +3720,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "rand_core", ] [[package]] @@ -3749,7 +3741,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -3770,18 +3762,17 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3801,7 +3792,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -3870,18 +3861,18 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] [[package]] name = "reqwest" -version = "0.13.1" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-core", "futures-util", @@ -3910,7 +3901,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -3959,7 +3950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3968,7 +3959,7 @@ version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3994,22 +3985,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -4043,13 +4034,13 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -4059,20 +4050,20 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.14" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -4177,20 +4168,20 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secret-service" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" dependencies = [ - "aes 0.8.4", - "cbc 0.1.2", + "aes", + "cbc", "futures-util", - "generic-array", - "getrandom 0.2.17", + "getrandom 0.4.3", "hkdf", + "hybrid-array", "num", "once_cell", "serde", - "sha2 0.10.9", + "sha2 0.11.0", "zbus", ] @@ -4200,8 +4191,8 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", - "core-foundation", + "bitflags 2.13.2", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4219,11 +4210,11 @@ dependencies = [ [[package]] name = "selectors" -version = "0.36.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cssparser", "derive_more", "log", @@ -4285,7 +4276,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -4305,6 +4296,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap 2.14.2", "itoa", "memchr", "serde", @@ -4320,7 +4312,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -4355,16 +4347,16 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.22.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.2", "jiff", "schemars 0.9.0", "schemars 1.2.2", @@ -4376,14 +4368,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.22.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -4435,7 +4427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -4450,13 +4442,13 @@ dependencies = [ [[package]] name = "shared_child" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +checksum = "607549934f6cc26b89cfecfdc46fa90f1e5d1536a68349b0c3a4f9d1c0d37959" dependencies = [ "libc", "sigchld", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4467,9 +4459,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "sigchld" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +checksum = "24f2b37f04360cd465089b87a9c3869c08220a2f3458463f0adf8badf5e77f2c" dependencies = [ "libc", "os_pipe", @@ -4478,9 +4470,9 @@ dependencies = [ [[package]] name = "signal-hook" -version = "0.3.18" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" dependencies = [ "libc", "signal-hook-registry", @@ -4502,6 +4494,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[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 = "similar" version = "3.2.0" @@ -4513,9 +4521,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -4525,9 +4533,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" dependencies = [ "serde", ] @@ -4539,7 +4547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4695,9 +4703,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -4715,13 +4723,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -4735,7 +4743,28 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows 0.62.2", + "windows", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -4753,13 +4782,13 @@ dependencies = [ [[package]] name = "tao" -version = "0.35.3" +version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -4769,24 +4798,24 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", + "ndk-context", "ndk-sys", "objc2", "objc2-app-kit", "objc2-foundation", "objc2-ui-kit", - "once_cell", "parking_lot", "percent-encoding", "raw-window-handle", "tao-macros", "unicode-segmentation", "url", - "windows 0.61.3", - "windows-core 0.61.2", + "windows", + "windows-core", "windows-version", "x11-dl", ] @@ -4821,9 +4850,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.5" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +checksum = "cb20ef8e6f97d6fe03039666cd82861f09a38b56291fa2f0f9fe140023f61c1a" dependencies = [ "anyhow", "bytes", @@ -4836,8 +4865,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "image", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -4850,7 +4878,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.1", + "reqwest 0.13.5", "serde", "serde_json", "serde_repr", @@ -4861,21 +4889,21 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tray-icon", "url", "webkit2gtk", "webview2-com", "window-vibrancy", - "windows 0.61.3", + "windows", ] [[package]] name = "tauri-build" -version = "2.6.3" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +checksum = "5b5ae674f48836f5dd2eeaf9e221c095bd3e78a8d0439c77ca93b48740a1b644" dependencies = [ "anyhow", "cargo_toml", @@ -4894,16 +4922,16 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.3" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +checksum = "3926b73ac01df7c14f19ff872cd56e67213a87acc0000ea37ddb300f3d11a3e3" dependencies = [ "base64 0.22.1", "brotli", "ico", "json-patch", "plist", - "png 0.17.16", + "png 0.18.1", "proc-macro2", "quote", "semver", @@ -4912,7 +4940,7 @@ dependencies = [ "sha2 0.10.9", "syn 2.0.119", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "url", "uuid", @@ -4921,9 +4949,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.3" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +checksum = "d21d0fa2c0529972ea6ecaf7404fcf40efac098eda0beb06ca682f41e33a6aa5" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -4935,9 +4963,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.6.3" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +checksum = "c9ff3ebb9fda56ceb93d46f6cbb126bae82951bf43498543a5163dde5def49a1" dependencies = [ "anyhow", "glob", @@ -4951,9 +4979,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.7.2" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +checksum = "7527c5fd1678032d116c1a82e5d609c67370e56f858ae6cfd376bbca37fe0490" dependencies = [ "log", "raw-window-handle", @@ -4963,15 +4991,15 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.20", + "thiserror 2.0.21", "url", ] [[package]] name = "tauri-plugin-fs" -version = "2.5.1" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +checksum = "466eb32466fe5021b6728b06862f9ff14a9f7e1af890047654e03a96d7c0e1bb" dependencies = [ "anyhow", "dunce", @@ -4986,16 +5014,16 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", + "thiserror 2.0.21", + "toml 1.1.6+spec-1.1.0", "url", ] [[package]] name = "tauri-plugin-process" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +checksum = "74ae3d8549ae564c0397377373e262f340908eb23a5b9fbe03f2a00007dc0038" dependencies = [ "tauri", "tauri-plugin", @@ -5003,9 +5031,9 @@ dependencies = [ [[package]] name = "tauri-plugin-shell" -version = "2.3.5" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +checksum = "094bdbc47e5efe36544ba21b3a53ab5c724150236cf55e6c9be46191deb6eab6" dependencies = [ "encoding_rs", "log", @@ -5018,15 +5046,31 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 2.0.20", + "thiserror 2.0.21", + "tokio", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561cd99484561bc39c954768e7b41a66cebbe1ac3d9d081c34361f740c489e2c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", ] [[package]] name = "tauri-plugin-updater" -version = "2.10.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +checksum = "3cb0b2ea3e85ca287990d3859a29cc5cb18024240fd78f62e027fb7963670f18" dependencies = [ "base64 0.22.1", "dirs", @@ -5038,7 +5082,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.13.1", + "reqwest 0.13.5", "rustls", "semver", "serde", @@ -5047,25 +5091,25 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tokio", "url", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "zip", ] [[package]] name = "tauri-runtime" -version = "2.11.3" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +checksum = "c9e6d5ef76985b32ff012a43b58df205d25a285e400ca878afcacddc5d5f3238" dependencies = [ "cookie", "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -5073,22 +5117,22 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.20", + "thiserror 2.0.21", "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows", ] [[package]] name = "tauri-runtime-wry" -version = "2.11.4" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +checksum = "fa829eb69860cf1fb0bedb21d2d5f1effce3527d054a68ca1cd7355c654400a3" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -5102,15 +5146,15 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows", "wry", ] [[package]] name = "tauri-utils" -version = "2.9.3" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +checksum = "4dd257082deff79550de3409ed146104aeaf86fe38649615b04a304144939677" dependencies = [ "anyhow", "brotli", @@ -5136,8 +5180,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.20", - "toml 1.1.4+spec-1.1.0", + "thiserror 2.0.21", + "toml 1.1.6+spec-1.1.0", "url", "urlpattern", "uuid", @@ -5152,7 +5196,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", ] [[package]] @@ -5165,7 +5209,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5188,11 +5232,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -5208,13 +5252,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -5268,18 +5312,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" @@ -5306,14 +5341,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -5347,26 +5382,11 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -5384,15 +5404,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -5408,7 +5419,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -5419,7 +5430,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -5428,11 +5439,11 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.4", @@ -5475,7 +5486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-core", "futures-util", @@ -5566,9 +5577,9 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" dependencies = [ "crossbeam-channel", "dirs", @@ -5582,8 +5593,8 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.20", - "windows-sys 0.61.2", + "thiserror 2.0.21", + "windows-sys 0.60.2", ] [[package]] @@ -5618,48 +5629,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", + "windows-sys 0.60.2", ] [[package]] @@ -5682,9 +5652,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-joining-type" @@ -5734,13 +5704,13 @@ dependencies = [ [[package]] name = "urlpattern" -version = "0.3.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" dependencies = [ + "icu_properties", "regex", "serde", - "unic-ucd-ident", "url", ] @@ -5752,9 +5722,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.24.1" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5843,9 +5813,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -5856,19 +5826,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5876,22 +5847,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -5909,11 +5880,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -6005,16 +5989,14 @@ dependencies = [ [[package]] name = "webview2-com" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows", + "windows-core", ] [[package]] @@ -6030,13 +6012,13 @@ dependencies = [ [[package]] name = "webview2-com-sys" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" dependencies = [ - "thiserror 2.0.20", - "windows 0.61.3", - "windows-core 0.61.2", + "thiserror 2.0.21", + "windows", + "windows-core", ] [[package]] @@ -6067,7 +6049,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6078,51 +6060,30 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "window-vibrancy" -version = "0.6.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" dependencies = [ "objc2", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "objc2-quartz-core", "raw-window-handle", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "windows-version", ] -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections 0.2.0", - "windows-core 0.61.2", - "windows-future 0.2.1", - "windows-link 0.1.3", - "windows-numerics 0.2.0", -] - [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections 0.3.2", - "windows-core 0.62.2", - "windows-future 0.3.2", - "windows-numerics 0.3.1", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] @@ -6131,20 +6092,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-core", ] [[package]] @@ -6155,20 +6103,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading 0.1.0", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -6177,9 +6114,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", - "windows-threading 0.2.1", + "windows-core", + "windows-link", + "windows-threading", ] [[package]] @@ -6204,12 +6141,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -6229,33 +6160,25 @@ dependencies = [ "zeroize", ] -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", + "windows-core", + "windows-link", ] [[package]] -name = "windows-result" -version = "0.3.4" +name = "windows-registry" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -6264,16 +6187,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -6282,7 +6196,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -6327,7 +6241,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -6367,7 +6281,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -6378,22 +6292,13 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -6402,7 +6307,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -6552,12 +6457,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - [[package]] name = "winnow" version = "1.0.4" @@ -6591,9 +6490,9 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" -version = "0.55.1" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" dependencies = [ "base64 0.22.1", "block2", @@ -6607,7 +6506,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -6622,13 +6521,13 @@ dependencies = [ "sha2 0.10.9", "soup3", "tao-macros", - "thiserror 2.0.20", + "thiserror 2.0.21", "url", "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows 0.61.3", - "windows-core 0.61.2", + "windows", + "windows-core", "windows-version", "x11-dl", ] @@ -6683,13 +6582,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] @@ -6748,7 +6647,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", "zbus_names", "zvariant", "zvariant_utils", @@ -6785,13 +6684,13 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] @@ -6831,7 +6730,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -6842,10 +6741,16 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", - "indexmap 2.14.0", + "indexmap 2.14.2", "memchr", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" @@ -6876,7 +6781,7 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", "zvariant_utils", ] @@ -6889,6 +6794,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.3", + "syn 3.0.6", "winnow 1.0.4", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 29289bc..68d5407 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "loach" -version = "1.5.0" +version = "1.6.0" description = "Native local LLM chat client (Ollama + OpenAI-compatible)." authors = ["Loach"] license = "MIT" edition = "2021" -rust-version = "1.88" +rust-version = "1.90" [lib] name = "loach_lib" @@ -15,21 +15,25 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["tray-icon", "image-png"] } +# No `image-png`: the tray icon is decoded at compile time by +# `include_image!`, and nothing loads an image from a path or bytes at runtime. +tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-dialog = "2" tauri-plugin-shell = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" +tauri-plugin-single-instance = "2" # Async runtime + HTTP tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls", "http2", "gzip", "brotli"], default-features = false } futures-util = "0.3" -async-stream = "0.3" # Serialization serde = { version = "1", features = ["derive"] } -serde_json = "1" +# `preserve_order` keeps object keys in document order, so the built-in `json` +# tool's `format` / `extract` give the model its own JSON back, not re-sorted. +serde_json = { version = "1", features = ["preserve_order"] } # Storage rusqlite = { version = "0.40", features = ["bundled"] } @@ -43,8 +47,7 @@ r2d2_sqlite = "0.35" # Secrets keyring = { version = "4", default-features = false, features = ["v1"] } -argon2 = "0.5" -rand_core = { version = "0.6", features = ["getrandom"] } +argon2 = "0.6" # Utilities uuid = { version = "1", features = ["v4", "v7", "serde"] } @@ -78,12 +81,17 @@ sha2 = "0.11" similar = "3" natord = "1" ipnet = "2" +# Grapheme clusters for the `count` tool's `graphemes` mode — the characters +# a reader sees, where `chars` counts scalar values. Already in the tree +# through tao, so declaring it costs no new crate. +unicode-segmentation = "1.13" # PDF generation for the built-in `pdf` tool. printpdf builds documents # from a structured spec and subsets a bundled Liberation Sans TTF (see # src-tauri/assets/fonts) into each file, so output supports Unicode — # Latin, European accents, common symbols — not just ASCII. Glyphs the -# font lacks (e.g. CJK, emoji) fall back to `?` at render time. +# font lacks (Cyrillic, Greek, CJK, emoji — the bundled cut is Latin-only) +# fall back to `?` at render time. # # `default-features = false` drops printpdf's `html` feature, which we never # touch — the tool builds documents from a structured spec, not from markup. @@ -133,7 +141,7 @@ chardetng = "1" # the Ollama catalog against the machine instead of quoting raw GB. Only the # `system` and `disk` collectors are compiled in — the default feature set also # pulls process enumeration, networks, components and users, none of which we -# read. Held at 0.38: 0.39 raises its MSRV to 1.95, above our 1.88. +# read. Held at 0.38: 0.39 raises its MSRV to 1.95, above our 1.90. sysinfo = { version = "0.38", default-features = false, features = ["system", "disk"] } # Windows VRAM + adapter name for the onboarding model recommendation, read via @@ -146,9 +154,11 @@ sysinfo = { version = "0.38", default-features = false, features = ["system", "d # signature) put each stdio MCP server in a job, so stopping the server stops # every process it started — see `mcp/stdio.rs`. `Win32_Storage_FileSystem` # is for `MoveFileExW`, the rename behind `move_file` that refuses to -# overwrite (`tools/fs.rs`). +# overwrite (`tools/fs.rs`). `Win32_Security_Authorization` is for +# `Get`/`SetNamedSecurityInfoW`, which carry a file's ACL over to the copy +# an edit renames into its place (`tools/fs.rs`). [target.'cfg(windows)'.dependencies] -windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Direct3D", "Win32_Graphics_Direct3D12", "Win32_System_JobObjects", "Win32_Security", "Win32_Storage_FileSystem"] } +windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Direct3D", "Win32_Graphics_Direct3D12", "Win32_System_JobObjects", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem"] } # `killpg` for the same purpose on macOS / Linux: a stdio MCP server runs in # its own process group and the whole group is stopped with it. Already in @@ -156,6 +166,19 @@ windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Graphics_Dx [target.'cfg(unix)'.dependencies] libc = "0.2" +# All three already in the tree on Linux (tauri / tao, and keyring's `v1` +# store), so declaring them costs no new crate. +# +# `gtk` shows the fatal startup dialog on the main thread's own GLib loop: +# `setup` runs inside tao's loop, which holds the default main context, so a +# dialog waited on from anywhere else never gets drawn (`lib.rs`). +# `keyring-core` + the Secret Service store rebuild the keyring store when +# keyring's one-shot init failed (`secrets::keyring_entry`). +[target.'cfg(target_os = "linux")'.dependencies] +gtk = "0.18" +keyring-core = "1" +zbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] } + [dev-dependencies] # Only used by the db.rs test suite, which opens a fresh database in a # tempdir per test so we don't trample the user's real `loach.db` and so diff --git a/src-tauri/assets/sandbox/preview.html b/src-tauri/assets/sandbox/preview.html new file mode 100644 index 0000000..131f928 --- /dev/null +++ b/src-tauri/assets/sandbox/preview.html @@ -0,0 +1,134 @@ + + + diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index e37eaf3..3518953 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -13,25 +13,32 @@ "gate). Granting them handed a compromised renderer window control it has", "no legitimate use for.", "", - "Not listed because core:default already covers them: is-maximized and", - "internal-toggle-maximize (core:window:default), app version", - "(core:app:default), and the event + path sets. process:default is", - "exit + restart." + "Not listed because the core:*:default sets below cover them: is-maximized", + "and internal-toggle-maximize (core:window:default), app version", + "(core:app:default), and the event + path sets. core:resources:default", + "closes the updater's Update handle. process:default is exit + restart.", + "", + "Deliberately NOT granted: core:default's image, menu and tray sets. The", + "renderer uses none of them, and core:image:default let it read any PNG", + "on disk through Image.fromPath, which has no path scope.", + "", + "shell:allow-open carries no URL scope: the shell plugin's open command", + "never reads one. What it may open is the plugins.shell.open regex in", + "tauri.conf.json (http, https and mailto only); left unset, the plugin's", + "default would also let tel: through." ], "permissions": [ - "core:default", + "core:app:default", + "core:event:default", + "core:path:default", + "core:resources:default", + "core:webview:default", + "core:window:default", "core:window:allow-minimize", "core:window:allow-toggle-maximize", "core:window:allow-close", "core:window:allow-start-dragging", - { - "identifier": "shell:allow-open", - "allow": [ - { "url": "https://*" }, - { "url": "http://*" }, - { "url": "mailto:*" } - ] - }, + "shell:allow-open", "updater:default", "process:default" ] diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png deleted file mode 100644 index eef82a8..0000000 Binary files a/src-tauri/icons/64x64.png and /dev/null differ diff --git a/src-tauri/icons/README.md b/src-tauri/icons/README.md index ac38b5b..9eaabbb 100644 --- a/src-tauri/icons/README.md +++ b/src-tauri/icons/README.md @@ -1,16 +1,22 @@ # Icons -Place the following Tauri bundle icons here before running `tauri build`: +The bundle icons listed under `bundle.icon` in `tauri.conf.json`: -- `32x32.png` +- `32x32.png` (also the window and tray icon on Linux: the first PNG in the list) - `128x128.png` - `128x128@2x.png` -- `icon.icns` (macOS, not strictly required for Windows/Linux targets) -- `icon.ico` (Windows) -- `icon.png` (tray icon, 256x256 recommended) +- `icon.icns` (macOS) +- `icon.ico` (Windows; also the window and tray icon there) -You can generate these from a single 1024x1024 source image with: +Plus `tray-template.png`, the macOS menu-bar icon (a template image, so the +system tints it), embedded by `src/lib.rs`. + +Regenerate the bundle icons from the 1024x1024 `source.png` with: ```bash -npm run tauri icon path/to/source.png +npm run tauri icon icons/source.png ``` + +`tauri icon` also writes Android, iOS, Windows Store (`Square*Logo.png`, +`StoreLogo.png`) and extra PNG sizes. Loach ships none of those targets, so +delete them after regenerating rather than committing them. diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png deleted file mode 100644 index 38fe5bf..0000000 Binary files a/src-tauri/icons/Square107x107Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png deleted file mode 100644 index 4369fcf..0000000 Binary files a/src-tauri/icons/Square142x142Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png deleted file mode 100644 index 05e82cd..0000000 Binary files a/src-tauri/icons/Square150x150Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png deleted file mode 100644 index f2cd269..0000000 Binary files a/src-tauri/icons/Square284x284Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png deleted file mode 100644 index 43bde93..0000000 Binary files a/src-tauri/icons/Square30x30Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png deleted file mode 100644 index 6a6ee9a..0000000 Binary files a/src-tauri/icons/Square310x310Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png deleted file mode 100644 index ce22952..0000000 Binary files a/src-tauri/icons/Square44x44Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png deleted file mode 100644 index 2479a04..0000000 Binary files a/src-tauri/icons/Square71x71Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png deleted file mode 100644 index 2719e89..0000000 Binary files a/src-tauri/icons/Square89x89Logo.png and /dev/null differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png deleted file mode 100644 index f22514a..0000000 Binary files a/src-tauri/icons/StoreLogo.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 2ffbf24..0000000 --- a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 7267ecf..0000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index fb9158a..0000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index f1b3b6a..0000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index d53dc62..0000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index b68be95..0000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index b304e13..0000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 269790f..0000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index be42f0d..0000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index 9be1beb..0000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 353fda0..0000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 61e370d..0000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 1f1c4ac..0000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index c8a8e64..0000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index bdfa9de..0000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 5e25293..0000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml deleted file mode 100644 index ea9c223..0000000 --- a/src-tauri/icons/android/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #fff - \ No newline at end of file diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png deleted file mode 100644 index 44fe336..0000000 Binary files a/src-tauri/icons/icon.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png deleted file mode 100644 index 7a55c2c..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png deleted file mode 100644 index 147483b..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png deleted file mode 100644 index 147483b..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png deleted file mode 100644 index 1bed82b..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png deleted file mode 100644 index 48d8b5e..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png deleted file mode 100644 index 61cfe12..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png deleted file mode 100644 index 61cfe12..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png deleted file mode 100644 index 2118361..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png deleted file mode 100644 index 147483b..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png deleted file mode 100644 index cf55323..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png deleted file mode 100644 index cf55323..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png deleted file mode 100644 index 978f676..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png deleted file mode 100644 index 9786022..0000000 Binary files a/src-tauri/icons/ios/AppIcon-512@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png deleted file mode 100644 index 978f676..0000000 Binary files a/src-tauri/icons/ios/AppIcon-60x60@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png deleted file mode 100644 index 04f7db2..0000000 Binary files a/src-tauri/icons/ios/AppIcon-60x60@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png deleted file mode 100644 index ad660a9..0000000 Binary files a/src-tauri/icons/ios/AppIcon-76x76@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png deleted file mode 100644 index 566b4e6..0000000 Binary files a/src-tauri/icons/ios/AppIcon-76x76@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png deleted file mode 100644 index 33c6356..0000000 Binary files a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and /dev/null differ diff --git a/src-tauri/icons/loach-icon.png b/src-tauri/icons/loach-icon.png deleted file mode 100644 index 1e43311..0000000 Binary files a/src-tauri/icons/loach-icon.png and /dev/null differ diff --git a/src-tauri/icons/tray-template.png b/src-tauri/icons/tray-template.png new file mode 100644 index 0000000..664368b Binary files /dev/null and b/src-tauri/icons/tray-template.png differ diff --git a/src-tauri/prebuilt-frontend.conf.json b/src-tauri/prebuilt-frontend.conf.json new file mode 100644 index 0000000..b850abf --- /dev/null +++ b/src-tauri/prebuilt-frontend.conf.json @@ -0,0 +1,3 @@ +{ + "build": { "beforeBuildCommand": "" } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 00182fe..2b04be8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,10 +3,15 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; +use crate::db::library::{ + LibraryDataset, LibraryItem, LibraryItemMeta, LibrarySource, LibraryUsage, NewLibraryItem, + SourceOwner, +}; use crate::db::{ DatabaseSnapshot, Folder, GlobalMemory, ImportStats, McpServer, Message, MessageHit, Session, Snippet, SnippetFillValue, SnippetVariable, Space, SpaceFile, SpaceMemory, StorageStats, }; +use crate::rag::{self, indexer::IndexStatus}; use crate::mcp::{self, McpTestResult}; use crate::providers::{self, ChatRequest, ModelInfo}; use crate::secrets; @@ -215,6 +220,26 @@ pub async fn set_session_folder( .map_err(err) } +#[derive(Debug, Deserialize)] +pub struct SetSessionSpaceArgs { + pub id: String, + /// Space to move the chat into. `None` takes it out of any Space. + #[serde(default)] + pub space_id: Option, +} + +/// Move a chat into a Space, or out of the one it's in. +#[tauri::command] +pub async fn set_session_space( + state: State<'_, AppState>, + args: SetSessionSpaceArgs, +) -> Result<(), String> { + state + .db + .set_session_space(&args.id, args.space_id.as_deref()) + .map_err(err) +} + // ---------- workspace directory ---------- /// Open the native folder picker and scope this chat's filesystem tools to @@ -403,56 +428,49 @@ pub async fn export_session( id: String, format: String, ) -> Result { - let mut session = state - .db - .get_session(&id) - .map_err(err)? - .ok_or_else(|| "session not found".to_string())?; - // Machine-local, and it names the user's folders — left out, the same - // as in a full backup. - session.workspace_root = None; - let messages = state.db.list_messages(&id).map_err(err)?; - - match format.as_str() { - "json" => { - let payload = serde_json::json!({ - "session": session, - "messages": messages, - }); - serde_json::to_string_pretty(&payload).map_err(err) - } - "md" | "markdown" => { - let mut out = String::new(); - out.push_str(&format!("# {}\n\n", session.title)); - out.push_str(&format!( - "_Provider: {} · Model: {}_\n\n", - session.provider, session.model - )); - if let Some(sys) = session.system_prompt.as_deref() { - if !sys.is_empty() { - out.push_str("## System prompt\n\n"); - out.push_str(sys); - out.push_str("\n\n"); - } + if format != "md" { + return Err(format!("unsupported format: {format}")); + } + // A whole transcript, inlined attachments and all, read and formatted — + // blocking work, so on the blocking pool like the other DB-heavy reads. + let db = state.db.clone(); + tokio::task::spawn_blocking(move || -> Result { + let session = db + .get_session(&id) + .map_err(err)? + .ok_or_else(|| "session not found".to_string())?; + let messages = db.list_messages(&id).map_err(err)?; + let mut out = String::new(); + out.push_str(&format!("# {}\n\n", session.title)); + out.push_str(&format!( + "_Provider: {} · Model: {}_\n\n", + session.provider, session.model + )); + if let Some(sys) = session.system_prompt.as_deref() { + if !sys.is_empty() { + out.push_str("## System prompt\n\n"); + out.push_str(sys); + out.push_str("\n\n"); } - for m in &messages { + } + for m in &messages { // Hidden imported rows are deliberately folded out of the // transcript; a user sharing an export shouldn't unknowingly // publish context they believe is tucked away. if m.import_hidden { continue; } - let role = match m.role.as_str() { - "user" => "You", - "assistant" => "Assistant", - _ => "System", - }; - out.push_str(&format!("## {role}\n\n{}\n\n", m.content)); - } - Ok(out) + let role = match m.role.as_str() { + "user" => "You", + "assistant" => "Assistant", + _ => "System", + }; + out.push_str(&format!("## {role}\n\n{}\n\n", m.content)); } - _ => Err(format!("unsupported format: {format}")), - } + Ok(out) + }) + .await + .map_err(|e| format!("export task panicked: {e}"))? } // ---------- messages ---------- @@ -701,9 +719,10 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result, + base_url: String, + api_key: String, +) -> Result, String> { + #[derive(Deserialize)] + struct Models { + data: Vec, + } + #[derive(Deserialize)] + struct Model { + id: String, + } + + providers::refuse_link_local_host(&base_url).await?; + let url = format!("{}/models", base_url.trim_end_matches('/')); + let mut req = state.http.get(url).timeout(std::time::Duration::from_secs(30)); + let key = api_key.trim(); + if !key.is_empty() && providers::openai::is_safe_for_bearer(&base_url) { + req = req.bearer_auth(key); + } + let resp = req.send().await.and_then(|r| r.error_for_status()).map_err(err)?; + let body: Models = resp.json().await.map_err(err)?; + Ok(body + .data + .into_iter() + .map(|m| ModelInfo { + id: m.id.clone(), + label: m.id, + provider: "openai".into(), + family: None, + size: None, + }) + .collect()) +} + // ---------- spaces ---------- #[tauri::command] @@ -1249,7 +1321,10 @@ pub async fn update_space( #[tauri::command] pub async fn delete_space(state: State<'_, AppState>, id: String) -> Result<(), String> { - state.db.delete_space(&id).map_err(err) + state.db.delete_space(&id).map_err(err)?; + // Its files' chunks went with it (triggers); let the index status catch up. + state.indexer.wake(); + Ok(()) } #[tauri::command] @@ -1276,9 +1351,11 @@ pub async fn add_space_file( state: State<'_, AppState>, args: AddSpaceFileArgs, ) -> Result { - state - .db - .add_space_file( + // Chunks and FTS-indexes the whole document under the writer lock — + // blocking work, so on the blocking pool. + let db = state.db.clone(); + let file = tokio::task::spawn_blocking(move || { + db.add_space_file( &args.space_id, &args.name, &args.mime, @@ -1287,7 +1364,12 @@ pub async fn add_space_file( args.size, args.position, ) - .map_err(err) + }) + .await + .map_err(err)? + .map_err(err)?; + state.indexer.wake(); + Ok(file) } #[tauri::command] @@ -1295,7 +1377,10 @@ pub async fn remove_space_file( state: State<'_, AppState>, file_id: String, ) -> Result<(), String> { - state.db.remove_space_file(&file_id).map_err(err) + state.db.remove_space_file(&file_id).map_err(err)?; + // Its chunks went with it (trigger); let the index status catch up. + state.indexer.wake(); + Ok(()) } #[derive(Debug, Serialize)] @@ -1878,20 +1963,26 @@ impl From for String { } } +/// The name heads the stdio consent dialog, so it gets the dialog's rules: +/// one short line that reads as what it is. +const MAX_MCP_NAME_CHARS: usize = 100; + +/// `screen_host: false` skips resolving and screening an HTTP server's host, +/// returning no addresses — for a row saved disabled, which connects to +/// nothing (see `mcp_save`). Every structural check still runs. async fn validate_mcp_input( input: &McpServerInput, + screen_host: bool, ) -> Result, McpImportRejection> { use McpImportRejection::{Rejected, Unscreenable}; if input.name.trim().is_empty() { return Err(Rejected("server name is required".into())); } - // The name heads the stdio consent dialog, so it gets the dialog's - // rules: one short line that reads as what it is. - const MAX_NAME_CHARS: usize = 100; - if input.name.chars().count() > MAX_NAME_CHARS || input.name.chars().any(unreviewable_char) { + if input.name.chars().count() > MAX_MCP_NAME_CHARS || input.name.chars().any(unreviewable_char) + { return Err(Rejected(format!( - "Server name must be a single line of at most {MAX_NAME_CHARS} characters, with no \ - control or invisible characters." + "Server name must be a single line of at most {MAX_MCP_NAME_CHARS} characters, with \ + no control or invisible characters." ))); } validate_allowed_tools(input)?; @@ -1930,22 +2021,6 @@ async fn validate_mcp_input( } } - // Resolve + screen the host: literal IPs are rejected only if link-local, - // hostnames are DNS-resolved and every returned address screened the same - // way. Returning the addresses lets the caller pin them into a per-request - // client at connect time so a malicious DNS server can't flip the answer - // from public to link-local between validate and dial. - let resolved = crate::tools::fetch_url::resolve_lan_addrs(&parsed) - .await - .map_err(|e| { - let msg = format!("MCP server URL rejected: {e}"); - match e { - // DNS didn't answer, so the host was never actually screened. - crate::tools::fetch_url::HostScreenError::Unresolvable(_) => Unscreenable(msg), - _ => Rejected(msg), - } - })?; - // Header k/v validation. We do NOT call out to the network here, so the // only protection we can offer is structural: reject names / values // that would let an attacker smuggle CRLF-injected headers via a @@ -1972,7 +2047,26 @@ async fn validate_mcp_input( } } - Ok(resolved) + if !screen_host { + return Ok(Vec::new()); + } + // Resolve + screen the host: literal IPs are rejected only if link-local, + // hostnames are DNS-resolved and every returned address screened the same + // way. Returning the addresses lets the caller pin them into a per-request + // client at connect time so a malicious DNS server can't flip the answer + // from public to link-local between validate and dial. Last, after the + // structural checks, so a row quarantined over a DNS failure on import + // has still had its headers checked. + crate::tools::fetch_url::resolve_lan_addrs(&parsed) + .await + .map_err(|e| { + let msg = format!("MCP server URL rejected: {e}"); + match e { + // DNS didn't answer, so the host was never actually screened. + crate::tools::fetch_url::HostScreenError::Unresolvable(_) => Unscreenable(msg), + _ => Rejected(msg), + } + }) } /// A character the consent dialog can't show faithfully: control @@ -2006,6 +2100,36 @@ fn unreviewable_char(c: char) -> bool { ) } +/// An imported server name brought within the name rule `validate_mcp_input` +/// enforces: the characters it refuses dropped, cut to +/// [`MAX_MCP_NAME_CHARS`], never empty and — being a name the user never +/// typed — not one another row in `taken` already has. `None` when the name +/// already passes. Names saved before the rule existed (≤1.4 took +/// "👨‍💻 dev", whose emoji is joined by a ZWJ) would otherwise sink the +/// whole restore. +fn sanitize_imported_mcp_name( + name: &str, + taken: &std::collections::HashSet, +) -> Option { + if !name.trim().is_empty() + && name.chars().count() <= MAX_MCP_NAME_CHARS + && !name.chars().any(unreviewable_char) + { + return None; + } + let kept: String = name.chars().filter(|&c| !unreviewable_char(c)).collect(); + let base = match kept.trim() { + "" => "MCP server", + s => s, + }; + (1..).find_map(|n: usize| { + let suffix = if n == 1 { String::new() } else { format!(" ({n})") }; + let head: String = base.chars().take(MAX_MCP_NAME_CHARS - suffix.chars().count()).collect(); + let candidate = format!("{}{suffix}", head.trim_end()); + (!taken.contains(&candidate)).then_some(candidate) + }) +} + /// Structural checks for a stdio row. There is no host to screen here; /// what we can refuse is the shape — characters the consent dialog can't /// show faithfully ([`unreviewable_char`]) anywhere in the command, @@ -2249,32 +2373,65 @@ fn stdio_consent_text(draft: &McpServer) -> Result { async fn confirm_stdio_spawn(app: &AppHandle, draft: &McpServer) -> Result<(), String> { let body = stdio_consent_text(draft)?; let title = format!("Run MCP server \"{}\"?", draft.name); + let answer = show_consent_dialog(app, title, body).await?; + if consent_given(&answer) { + Ok(()) + } else { + Err("Cancelled — the server was not started.".into()) + } +} + +fn consent_buttons() -> rfd::MessageButtons { + rfd::MessageButtons::OkCancelCustom(CONSENT_CANCEL.into(), CONSENT_START.into()) +} - // rfd shows dialogs from the main thread (macOS and GTK require it); - // the future it hands back can be awaited anywhere. +#[cfg(not(target_os = "linux"))] +async fn show_consent_dialog( + app: &AppHandle, + title: String, + body: String, +) -> Result { + // rfd shows dialogs from the main thread (macOS requires it); the + // future it hands back can be awaited anywhere. let (tx, rx) = tokio::sync::oneshot::channel(); app.run_on_main_thread(move || { let answer = rfd::AsyncMessageDialog::new() .set_level(rfd::MessageLevel::Warning) .set_title(title) .set_description(body) - .set_buttons(rfd::MessageButtons::OkCancelCustom( - CONSENT_CANCEL.into(), - CONSENT_START.into(), - )) + .set_buttons(consent_buttons()) .show(); let _ = tx.send(answer); }) .map_err(|e| format!("couldn't show the consent dialog: {e}"))?; - let answer = rx + Ok(rx .await .map_err(|_| "the consent dialog couldn't be shown".to_string())? - .await; - if consent_given(&answer) { - Ok(()) - } else { - Err("Cancelled — the server was not started.".into()) - } + .await) +} + +/// GTK's async dialog answers only `Ok` / `Cancel`, never `Custom`, and Esc +/// comes back as `Cancel` too — so "Start server" (the second button) read +/// as a dismissal and no stdio server could ever be started on Linux. The +/// blocking dialog maps each button to its label and Esc / close to +/// `Cancel`. rfd runs GTK dialogs on its own thread, so it's driven from the +/// blocking pool rather than the main thread. +#[cfg(target_os = "linux")] +async fn show_consent_dialog( + _app: &AppHandle, + title: String, + body: String, +) -> Result { + tokio::task::spawn_blocking(move || { + rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Warning) + .set_title(title) + .set_description(body) + .set_buttons(consent_buttons()) + .show() + }) + .await + .map_err(|_| "the consent dialog couldn't be shown".to_string()) } /// The consent dialog's buttons. See [`confirm_stdio_spawn`] for the order. @@ -2360,8 +2517,13 @@ pub async fn mcp_save( // We don't pin DNS on save (no network call), but we do still resolve // and screen so a save can't smuggle a private-IP-backed hostname into // the DB for a later, weaker code path to pick up. - validate_mcp_input(&input).await?; + // + // Except for a row saved disabled: nothing connects to it, and turning it + // back on is another save that screens. Asking DNS here only stopped the + // user switching off or renaming a server whose host doesn't resolve + // from where they are (offline, off the VPN). let mut draft = input.to_draft(); + validate_mcp_input(&input, draft.enabled).await?; // "Always allow" was an answer about the program or endpoint it was // given for. A save that points the row at a different one starts the // list over, so `write_file` allowed for one filesystem server doesn't @@ -2423,7 +2585,7 @@ pub async fn mcp_test( state: State<'_, AppState>, input: McpServerInput, ) -> Result { - let addrs = validate_mcp_input(&input).await?; + let addrs = validate_mcp_input(&input, true).await?; let draft = input.to_draft(); if draft.is_stdio() { require_stdio_consent(&app, &state, &draft, true).await?; @@ -2510,7 +2672,7 @@ pub async fn chat_stream( biased; _ = cancel.notified() => { let _ = app.emit(&channel, crate::stream::StreamEvent::Cancelled); - registry.finish(&request.stream_id); + registry.finish_own(&request.stream_id, &cancel); return; } r = agg_fut => r, @@ -2612,7 +2774,7 @@ pub async fn chat_stream( biased; _ = cancel.notified() => { let _ = app.emit(&channel, crate::stream::StreamEvent::Cancelled); - registry.finish(&request.stream_id); + registry.finish_own(&request.stream_id, &cancel); return; } r = lookup => r.unwrap_or((None, None)), @@ -2680,9 +2842,10 @@ pub async fn chat_stream( // bare names used by built-ins (`calculate`, `datetime`, …) // can't collide with `resolve_qualified`'s first-match scan. // - // Built-ins are purely local (no network, no DB writes), so we - // expose them in Private Chat too — the privacy guarantee is - // about data leaving the box, not about hiding local compute. + // Built-ins are purely local (no network, no DB writes), so a + // private request could take them — but Private Chat sends + // `no_tools`: its overlay has nowhere to show a tool call, and a + // generated file (the pdf tool) was silently lost. if !request.no_tools { request.tools.extend(crate::tools::builtin::enabled_builtin_defs( &db, @@ -2716,7 +2879,7 @@ pub async fn chat_stream( ), }, ); - registry.finish(&request.stream_id); + registry.finish_own(&request.stream_id, &cancel); Ok(()) } }; @@ -2748,6 +2911,517 @@ pub async fn chat_cancel_all(state: State<'_, AppState>) -> Result<(), String> { Ok(()) } +// ---------- library ---------- +// +// Items (texts, files, links) stored once and linked into Spaces and chats. +// Every write that adds or changes text wakes the indexer, which embeds the +// new chunks in the background; keyword search works the moment the write +// commits. See `db::library` and `rag`. + +/// Longest body a Library item keeps, in characters. Matches the frontend's +/// per-file extraction cap (`MAX_EXTRACTED_CHARS` in `src/lib/files.ts`). +const LIBRARY_MAX_CHARS: usize = 200_000; + +fn embed_model_key(state: &AppState) -> Option { + rag::embed::EmbedConfig::from_db(&state.db).map(|c| c.model_key()) +} + +/// A Library or dataset title on one line: whitespace runs, newlines +/// included, collapse to a space — titles label lists, chips and prompt lines. +fn library_title(title: &str, fallback: &str) -> String { + let t = title.split_whitespace().collect::>().join(" "); + if t.is_empty() { + fallback.to_string() + } else { + t + } +} + +/// Room past [`LIBRARY_MAX_CHARS`] for the note the frontend appends to a +/// body it already cut (`[…file truncated…]`), so the model still reads it. +const LIBRARY_NOTE_ROOM: usize = 1_000; + +/// Cut a text body to [`LIBRARY_MAX_CHARS`], saying so at the cut. Returns +/// whether it was cut. +fn cap_library_text(mut content: String) -> (String, bool) { + if content.char_indices().nth(LIBRARY_MAX_CHARS + LIBRARY_NOTE_ROOM).is_none() { + return (content, false); + } + if let Some((at, _)) = content.char_indices().nth(LIBRARY_MAX_CHARS) { + content.truncate(at); + } + content.push_str("\n\n[…truncated at 200,000 characters]"); + (content, true) +} + +/// A fetched page's `` on one line and of sane length — it's shown +/// in lists and labels the page in prompts. +fn link_title(raw: &str) -> String { + let flat = raw.split_whitespace().collect::<Vec<_>>().join(" "); + match flat.char_indices().nth(200) { + Some((at, _)) => format!("{}…", flat[..at].trim_end()), + None => flat, + } +} + +/// A link's URL without any `user:password@`: the Library keeps the URL as +/// plain text and exports it, so credentials in it would sit in the database +/// and every backup. Any other URL is returned exactly as given. +fn strip_url_credentials(raw: &str) -> String { + match reqwest::Url::parse(raw) { + Ok(mut url) if !url.username().is_empty() || url.password().is_some() => { + let _ = url.set_username(""); + let _ = url.set_password(None); + url.to_string() + } + _ => raw.to_string(), + } +} + +#[derive(Debug, Serialize)] +pub struct LibraryList { + pub items: Vec<LibraryItemMeta>, + pub datasets: Vec<LibraryDataset>, +} + +#[tauri::command] +pub async fn library_list(state: State<'_, AppState>) -> Result<LibraryList, String> { + let key = embed_model_key(&state); + Ok(LibraryList { + items: state.db.list_library_items(key.as_deref()).map_err(err)?, + datasets: state.db.list_library_datasets().map_err(err)?, + }) +} + +#[tauri::command] +pub async fn library_get_items( + state: State<'_, AppState>, + ids: Vec<String>, +) -> Result<Vec<LibraryItem>, String> { + let db = state.db.clone(); + tokio::task::spawn_blocking(move || db.get_library_items(&ids)) + .await + .map_err(err)? + .map_err(err) +} + +#[derive(Debug, Deserialize)] +pub struct CreateLibraryItemArgs { + /// `"text"` or `"file"` — links go through `library_add_link`. + pub kind: String, + pub title: String, + #[serde(default)] + pub mime: String, + /// `"text"` or `"image"`. + pub media: String, + pub content: String, + #[serde(default)] + pub size: i64, + #[serde(default)] + pub truncated: bool, +} + +#[tauri::command] +pub async fn library_create_item( + state: State<'_, AppState>, + args: CreateLibraryItemArgs, +) -> Result<LibraryItemMeta, String> { + if !matches!(args.kind.as_str(), "text" | "file") { + return Err(format!("can't create a `{}` item directly", args.kind)); + } + let title = library_title(&args.title, "Untitled"); + // Images are base64 and stay whole; text is held to the same cap as a + // fetched page, whatever the frontend already did. + let (content, cut) = if args.media == "text" { + cap_library_text(args.content) + } else { + (args.content, false) + }; + let key = embed_model_key(&state); + let db = state.db.clone(); + let meta = tokio::task::spawn_blocking(move || { + db.create_library_item( + &NewLibraryItem { + kind: &args.kind, + title: &title, + mime: &args.mime, + media: &args.media, + content: &content, + size: args.size, + url: None, + fetched_at: None, + truncated: args.truncated || cut, + }, + key.as_deref(), + ) + }) + .await + .map_err(err)? + .map_err(err)?; + state.indexer.wake(); + Ok(meta) +} + +/// Rename an item. Returns the stored row, so the frontend's copy carries +/// the server's `updated_at` (its body cache is keyed on it). +#[tauri::command] +pub async fn library_rename_item( + state: State<'_, AppState>, + id: String, + title: String, +) -> Result<LibraryItemMeta, String> { + let key = embed_model_key(&state); + let db = state.db.clone(); + // Re-issues every chunk of the item — on the blocking pool, like the + // other Library writes. + let meta = tokio::task::spawn_blocking(move || { + db.rename_library_item(&id, &library_title(&title, "Untitled"), key.as_deref()) + }) + .await + .map_err(err)? + .map_err(err)?; + // The title is embedded with every chunk, so a new one means new vectors. + state.indexer.wake(); + Ok(meta) +} + +/// Save an edited text item. +#[tauri::command] +pub async fn library_update_text( + state: State<'_, AppState>, + id: String, + title: String, + content: String, +) -> Result<LibraryItemMeta, String> { + let title = library_title(&title, "Untitled"); + let key = embed_model_key(&state); + let db = state.db.clone(); + let meta = tokio::task::spawn_blocking(move || { + let size = content.len() as i64; + let (content, cut) = cap_library_text(content); + // Editing a cut document doesn't bring back what was cut. + let was_cut = db + .get_library_items(std::slice::from_ref(&id))? + .pop() + .is_some_and(|i| i.truncated); + db.replace_library_content(&id, Some(&title), &content, None, size, cut || was_cut, None, key.as_deref()) + }) + .await + .map_err(err)? + .map_err(err)?; + state.indexer.wake(); + Ok(meta) +} + +/// Fetch a page through the SSRF-guarded fetcher and refuse bodies that +/// aren't text — a PDF or image link decoded as UTF-8 is noise. +async fn fetch_for_library(url: &str) -> Result<FetchedPage, String> { + let page = fetch_url_tool::fetch_capped(url, LIBRARY_MAX_CHARS).await?; + let ct = page.content_type.to_ascii_lowercase(); + if !is_textual_content_type(&ct) { + return Err(format!( + "This link serves `{}`, which can't be read as text. Download it and add it under Files instead.", + ct.split(';').next().unwrap_or(&ct).trim() + )); + } + if page.text.trim().is_empty() { + return Err("The page has no readable text.".to_string()); + } + Ok(page) +} + +/// Whether a (lowercased) `Content-Type` can be read as text. Office Open +/// XML (`application/vnd.openxmlformats-officedocument.*` — .docx, .xlsx, +/// .pptx) names XML but is a zip archive, so it's refused like any binary. +fn is_textual_content_type(ct: &str) -> bool { + ct.is_empty() + || ct.contains("html") + || ct.starts_with("text/") + || ct.contains("json") + || (ct.contains("xml") && !ct.contains("openxmlformats")) +} + +fn link_mime(page: &FetchedPage) -> String { + page.content_type.split(';').next().unwrap_or("").trim().to_string() +} + +/// Add a web page as a Library item: fetched once, stored as a dated +/// snapshot, refreshed only when the user asks. +#[tauri::command] +pub async fn library_add_link( + state: State<'_, AppState>, + url: String, +) -> Result<LibraryItemMeta, String> { + let url = strip_url_credentials(url.trim()); + let page = fetch_for_library(&url).await?; + let title = page + .title + .as_deref() + .map(link_title) + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| link_title(&page.final_url)); + let key = embed_model_key(&state); + let db = state.db.clone(); + let now = chrono::Utc::now().timestamp_millis(); + let meta = tokio::task::spawn_blocking(move || { + db.create_library_item( + &NewLibraryItem { + kind: "link", + title: &title, + mime: &link_mime(&page), + media: "text", + content: &page.text, + size: page.bytes as i64, + url: Some(&url), + fetched_at: Some(now), + truncated: page.truncated, + }, + key.as_deref(), + ) + }) + .await + .map_err(err)? + .map_err(err)?; + state.indexer.wake(); + Ok(meta) +} + +/// Re-fetch a link item and replace its snapshot. +#[tauri::command] +pub async fn library_refresh_link( + state: State<'_, AppState>, + id: String, +) -> Result<LibraryItemMeta, String> { + let item = state + .db + .get_library_items(std::slice::from_ref(&id)) + .map_err(err)? + .pop() + .ok_or("library item not found")?; + let url = item.url.filter(|_| item.kind == "link").ok_or("not a link")?; + let page = fetch_for_library(&url).await?; + let key = embed_model_key(&state); + let db = state.db.clone(); + let now = chrono::Utc::now().timestamp_millis(); + let meta = tokio::task::spawn_blocking(move || { + db.replace_library_content( + &id, + None, + &page.text, + Some(&link_mime(&page)), + page.bytes as i64, + page.truncated, + Some(now), + key.as_deref(), + ) + }) + .await + .map_err(err)? + .map_err(err)?; + state.indexer.wake(); + Ok(meta) +} + +#[tauri::command] +pub async fn library_delete_items(state: State<'_, AppState>, ids: Vec<String>) -> Result<(), String> { + state.db.delete_library_items(&ids).map_err(err)?; + // Their chunks went with them (trigger); let the index status catch up. + state.indexer.wake(); + Ok(()) +} + +#[tauri::command] +pub async fn library_usage( + state: State<'_, AppState>, + item_ids: Vec<String>, + dataset_ids: Vec<String>, +) -> Result<LibraryUsage, String> { + state.db.library_usage(&item_ids, &dataset_ids).map_err(err) +} + +#[tauri::command] +pub async fn library_create_dataset( + state: State<'_, AppState>, + name: String, + description: String, + item_ids: Vec<String>, +) -> Result<LibraryDataset, String> { + state + .db + .create_library_dataset(&library_title(&name, "Untitled dataset"), description.trim(), &item_ids) + .map_err(err) +} + +#[tauri::command] +pub async fn library_update_dataset( + state: State<'_, AppState>, + id: String, + name: String, + description: String, +) -> Result<(), String> { + state + .db + .update_library_dataset(&id, &library_title(&name, "Untitled dataset"), description.trim()) + .map_err(err) +} + +#[tauri::command] +pub async fn library_set_dataset_items( + state: State<'_, AppState>, + id: String, + item_ids: Vec<String>, +) -> Result<Vec<String>, String> { + state.db.set_library_dataset_items(&id, &item_ids).map_err(err) +} + +#[tauri::command] +pub async fn library_delete_dataset(state: State<'_, AppState>, id: String) -> Result<(), String> { + state.db.delete_library_dataset(&id).map_err(err) +} + +#[tauri::command] +pub async fn library_list_sources( + state: State<'_, AppState>, + owner: String, + owner_id: String, +) -> Result<Vec<LibrarySource>, String> { + let owner = SourceOwner::parse(&owner).map_err(err)?; + state.db.list_library_sources(owner, &owner_id).map_err(err) +} + +#[tauri::command] +pub async fn library_add_sources( + state: State<'_, AppState>, + owner: String, + owner_id: String, + item_ids: Vec<String>, + dataset_ids: Vec<String>, +) -> Result<Vec<LibrarySource>, String> { + let owner = SourceOwner::parse(&owner).map_err(err)?; + state + .db + .add_library_sources(owner, &owner_id, &item_ids, &dataset_ids) + .map_err(err) +} + +#[tauri::command] +pub async fn library_remove_source( + state: State<'_, AppState>, + owner: String, + id: String, +) -> Result<(), String> { + let owner = SourceOwner::parse(&owner).map_err(err)?; + state.db.remove_library_source(owner, &id).map_err(err) +} + +#[tauri::command] +pub async fn library_set_source_enabled( + state: State<'_, AppState>, + owner: String, + id: String, + enabled: bool, +) -> Result<(), String> { + let owner = SourceOwner::parse(&owner).map_err(err)?; + state.db.set_library_source_enabled(owner, &id, enabled).map_err(err) +} + +#[tauri::command] +pub async fn set_space_file_enabled( + state: State<'_, AppState>, + file_id: String, + enabled: bool, +) -> Result<(), String> { + state.db.set_space_file_enabled(&file_id, enabled).map_err(err) +} + +fn check_retrieval_mode(mode: &str) -> Result<(), String> { + if crate::db::is_retrieval_mode(mode) { + Ok(()) + } else { + Err(format!("unknown retrieval mode `{mode}`")) + } +} + +#[tauri::command] +pub async fn set_space_retrieval_mode( + state: State<'_, AppState>, + id: String, + mode: String, +) -> Result<(), String> { + check_retrieval_mode(&mode)?; + state.db.set_space_retrieval_mode(&id, &mode).map_err(err) +} + +/// `mode: None` makes the chat inherit its Space's mode again. +#[tauri::command] +pub async fn set_session_retrieval_mode( + state: State<'_, AppState>, + id: String, + mode: Option<String>, +) -> Result<(), String> { + if let Some(m) = &mode { + check_retrieval_mode(m)?; + } + state.db.set_session_retrieval_mode(&id, mode.as_deref()).map_err(err) +} + +fn check_image_scaling(json: Option<String>) -> Result<Option<String>, String> { + json.map(|j| { + crate::db::normalize_image_scaling(&j).ok_or_else(|| format!("invalid image scaling `{j}`")) + }) + .transpose() +} + +/// `scaling: None` makes the Space inherit the app's image scaling again. +#[tauri::command] +pub async fn set_space_image_scaling( + state: State<'_, AppState>, + id: String, + scaling: Option<String>, +) -> Result<(), String> { + let scaling = check_image_scaling(scaling)?; + state.db.set_space_image_scaling(&id, scaling.as_deref()).map_err(err) +} + +/// `scaling: None` makes the chat inherit its Space's (or the app's) image +/// scaling again. +#[tauri::command] +pub async fn set_session_image_scaling( + state: State<'_, AppState>, + id: String, + scaling: Option<String>, +) -> Result<(), String> { + let scaling = check_image_scaling(scaling)?; + state.db.set_session_image_scaling(&id, scaling.as_deref()).map_err(err) +} + +/// Find the excerpts of `args.docs` most relevant to `args.query`. +#[tauri::command] +pub async fn rag_search( + state: State<'_, AppState>, + args: rag::SearchArgs, +) -> Result<rag::SearchResult, String> { + rag::search(state.db.clone(), &state.http, &state.indexer, args).await +} + +#[tauri::command] +pub async fn rag_status(state: State<'_, AppState>) -> Result<IndexStatus, String> { + Ok(state.indexer.status()) +} + +/// Drop every embedding and re-embed from scratch — after swapping the +/// model for one with the same name, or to recover from a bad index. +#[tauri::command] +pub async fn rag_reindex(state: State<'_, AppState>) -> Result<(), String> { + let db = state.db.clone(); + tokio::task::spawn_blocking(move || db.clear_rag_embeddings()) + .await + .map_err(err)? + .map_err(err)?; + state.indexer.wake(); + Ok(()) +} + // ---------- tools ---------- /// Fetch a public URL and return cleaned, bounded text the frontend can @@ -2910,7 +3584,7 @@ pub async fn save_binary_to_file( /// Row counts and byte totals for the Settings -> Data storage tile. /// -/// On the blocking pool for the same reason as `export_data_json`: the +/// On the blocking pool for the same reason as `export_data_to_file`: the /// `SUM(LENGTH(...))` scans walk every message and space file, including /// their inlined base64 bodies, so on an attachment-heavy database this is /// firmly CPU-bound and would otherwise stall a runtime worker. @@ -2922,21 +3596,61 @@ pub async fn storage_stats(state: State<'_, AppState>) -> Result<StorageStats, S .map_err(|e| format!("storage stats task panicked: {e}"))? } -/// Serialise every table to a JSON string. Pretty-printed so users can -/// diff or grep an export manually. The schema tag `"loach/v1"` is the -/// forward-compat knob — `import_data_with_dialog` rejects anything else. +/// Open a native save dialog and write every table, as JSON, to the path the +/// user picks. Returns that path, or `None` if they cancelled. Pretty-printed +/// so users can diff or grep an export manually. The schema tag +/// (`db::SNAPSHOT_SCHEMA`) is the forward-compat knob — +/// `import_data_with_dialog` rejects any tag this build can't read. +/// +/// The snapshot streams straight into the file. It used to come back to the +/// renderer as one string and go out again through `save_text_to_file`, +/// which held several copies of it in the JS heap and then refused anything +/// over `MAX_SAVE_BYTES` only after all of them had been built. #[tauri::command] -pub async fn export_data_json(state: State<'_, AppState>) -> Result<String, String> { +pub async fn export_data_to_file( + app: AppHandle, + state: State<'_, AppState>, +) -> Result<Option<String>, String> { + use std::io::Write as _; + use tauri_plugin_dialog::DialogExt; + + let file_name = format!("loach-export-{}.json", chrono::Utc::now().format("%Y-%m-%d")); + let path_opt = tauri::async_runtime::spawn_blocking(move || { + app.dialog() + .file() + .set_file_name(&file_name) + .add_filter("Loach export", &["json"]) + .blocking_save_file() + }) + .await + .map_err(|e| format!("save dialog task failed: {e}"))?; + + let chosen = match path_opt { + Some(fp) => fp, + None => return Ok(None), + }; + let path = chosen + .into_path() + .map_err(|e| format!("invalid path returned from dialog: {e}"))?; + // `snapshot()` reads every table (messages and space files carry inlined - // base64 bodies) under the DB lock, and the pretty-print doubles that in - // memory — both CPU-bound and both blocking. The import counterpart is - // already on the blocking pool for exactly this reason; the export half - // wasn't, so a large attachment-heavy database stalled a runtime worker - // and queued every other command behind it. + // base64 bodies) under the DB lock, and the write that follows is as big + // — both blocking, so both on the blocking pool, like the import. let db = state.db.clone(); - tokio::task::spawn_blocking(move || -> Result<String, String> { + tokio::task::spawn_blocking(move || -> Result<Option<String>, String> { let snap = db.snapshot().map_err(err)?; - serde_json::to_string_pretty(&snap).map_err(err) + let failed = |e: std::io::Error| format!("couldn't write {}: {e}", path.display()); + let mut out = std::io::BufWriter::new(std::fs::File::create(&path).map_err(failed)?); + let written = serde_json::to_writer_pretty(&mut out, &snap) + .map_err(std::io::Error::from) + .and_then(|()| out.flush()); + if let Err(e) = written { + drop(out); + // A cut-off export reads as a backup until the day it won't import. + let _ = std::fs::remove_file(&path); + return Err(failed(e)); + } + Ok(Some(path.to_string_lossy().to_string())) }) .await .map_err(|e| format!("export task panicked: {e}"))? @@ -2993,10 +3707,11 @@ pub async fn import_data_with_dialog( .map_err(|e| format!("couldn't read {}: {e}", path.display()))?; let snap: DatabaseSnapshot = serde_json::from_str(&text) .map_err(|e| format!("file doesn't look like a Loach export: {e}"))?; - if snap.schema != "loach/v1" { + if !crate::db::is_supported_snapshot_schema(&snap.schema) { return Err(format!( - "unsupported export schema '{}' — this build expects 'loach/v1'", - snap.schema + "unsupported export schema '{}' — this build reads 'loach/v1' and '{}'", + snap.schema, + crate::db::SNAPSHOT_SCHEMA )); } Ok(snap) @@ -3014,7 +3729,18 @@ pub async fn import_data_with_dialog( // importing a tampered file. let mut quarantined: Vec<String> = Vec::new(); let mut stdio_parked: Vec<String> = Vec::new(); + let mut renamed: Vec<String> = Vec::new(); + let mut taken: std::collections::HashSet<String> = + snap.data.mcp_servers.iter().map(|r| r.name.clone()).collect(); for (idx, row) in snap.data.mcp_servers.iter_mut().enumerate() { + // A name the rule refuses is fixed up rather than failing the import: + // it's a label, and a backup from before the rule shouldn't cost the + // user every chat in it. + if let Some(name) = sanitize_imported_mcp_name(&row.name, &taken) { + renamed.push(format!("{:?} → {name:?}", row.name)); + taken.insert(name.clone()); + row.name = name; + } let parsed_headers: Option<std::collections::HashMap<String, String>> = parse_import_json(idx, &row.name, "headers_json", row.headers_json.as_deref())?; let parsed_env: Option<std::collections::HashMap<String, String>> = @@ -3040,7 +3766,7 @@ pub async fn import_data_with_dialog( allowed_tools: parsed_allowed, enabled: Some(row.enabled), }; - match validate_mcp_input(&synthetic).await { + match validate_mcp_input(&synthetic, true).await { Ok(_) => { // A stdio row is a command line nobody has confirmed on // *this* machine, in *this* session — and a snapshot is the @@ -3104,6 +3830,13 @@ pub async fn import_data_with_dialog( quarantined.join(", ") ); } + if !renamed.is_empty() { + tracing::info!( + "import: {} MCP server name(s) didn't fit the name rule and were renamed: {}", + renamed.len(), + renamed.join(", ") + ); + } if !stdio_parked.is_empty() { tracing::info!( "import: {} stdio MCP server(s) imported disabled pending consent: {}", @@ -3115,11 +3848,11 @@ pub async fn import_data_with_dialog( // Stage 2.5 — filter imported settings through the SAME allowlist // `set_setting` enforces. `restore_snapshot` writes settings rows // verbatim, which otherwise bypasses the gate entirely: a hand-edited - // export could plant arbitrary keys or repoint `openai_base_url` at an - // attacker host — the exact attack `set_setting`'s allowlist closes. - // (We deliberately do NOT value-screen base URLs: they're freely - // user-writable on the normal path — local proxies like LM Studio need - // `localhost:1234` — so the import path matches that.) + // export could plant arbitrary keys. (We deliberately do NOT + // value-screen base URLs: they're freely user-writable on the normal + // path — local proxies like LM Studio need `localhost:1234` — so the + // import path matches that. Stage 2.6 handles the one URL a stored + // secret is sent to.) let before = snap.data.settings.len(); snap.data .settings @@ -3131,6 +3864,23 @@ pub async fn import_data_with_dialog( ); } + // Stage 2.6 — the allowlist above admits `openai_base_url`, so a backup + // could still repoint it. Keep the current URL while a key is stored + // (a failed keyring read counts as stored). + let key_may_exist = + tokio::task::spawn_blocking(|| !matches!(secrets::get_openai_key(), Ok(None))) + .await + .unwrap_or(true); + if key_may_exist { + let current = state.db.get_setting(OPENAI_BASE_URL_KEY).map_err(err)?; + if keep_current_openai_base_url(&mut snap.data.settings, current) { + tracing::warn!( + "import: kept the current OpenAI base URL — an API key is stored for it \ + and the backup named a different server" + ); + } + } + // Stage 3 — apply. `restore_snapshot` holds the single DB mutex for // the duration of the transaction and inserts every row serially, so // it has to run on the blocking pool too. @@ -3147,9 +3897,37 @@ pub async fn import_data_with_dialog( crate::mcp::drop_all_sessions(); // Every chat was replaced, and with them what "this chat" meant. state.approvals.revoke_all(); + // Snapshots don't carry the retrieval index; rebuild it. + state.indexer.wake(); Ok(Some(stats)) } +const OPENAI_BASE_URL_KEY: &str = "openai_base_url"; + +/// Replace an imported `openai_base_url` with `current` (the installed value, +/// `None` when it's the default). The API key never travels in a snapshot, so +/// the one in the keyring belongs to the server configured here; a backup +/// naming another server would otherwise have that key sent there on the +/// next model listing, chat or embedding request, with no click from the +/// user. Returns whether the snapshot named a different URL. +fn keep_current_openai_base_url( + settings: &mut Vec<(String, String)>, + current: Option<String>, +) -> bool { + let imported = settings + .iter() + .find(|(k, _)| k == OPENAI_BASE_URL_KEY) + .map(|(_, v)| v.clone()); + if imported == current { + return false; + } + settings.retain(|(k, _)| k != OPENAI_BASE_URL_KEY); + if let Some(url) = current { + settings.push((OPENAI_BASE_URL_KEY.to_string(), url)); + } + true +} + /// Decode one JSON column of an imported MCP row, naming the row and the /// column in the rejection so a hand-edited export fails legibly. fn parse_import_json<T: serde::de::DeserializeOwned>( @@ -3207,7 +3985,13 @@ pub async fn wipe_user_data( auth: Option<DestructiveAuthArgs>, ) -> Result<(), String> { check_destructive_auth(auth).await?; - state.db.wipe_user_data().map_err(err)?; + // Bulk DELETEs, each chunk row through its FTS trigger — on the blocking + // pool. + let db = state.db.clone(); + tokio::task::spawn_blocking(move || db.wipe_user_data()) + .await + .map_err(err)? + .map_err(err)?; // The wipe deletes every `mcp_servers` row, so the cached tool catalogue // is now stale. The webview reloads afterwards but `AppState` doesn't, so // without this the next chat send would still advertise the just-erased @@ -3215,6 +3999,7 @@ pub async fn wipe_user_data( crate::mcp::invalidate_tools_cache(&state.mcp_tools_cache).await; crate::mcp::drop_all_sessions(); state.approvals.revoke_all(); + state.indexer.wake(); Ok(()) } @@ -3233,12 +4018,18 @@ pub async fn factory_reset( auth: Option<DestructiveAuthArgs>, ) -> Result<(), String> { check_destructive_auth(auth).await?; - state.db.wipe_all().map_err(err)?; + // Blocking bulk DELETEs, as in `wipe_user_data`. + let db = state.db.clone(); + tokio::task::spawn_blocking(move || db.wipe_all()) + .await + .map_err(err)? + .map_err(err)?; // Same reason as in `wipe_user_data`: the MCP rows are gone, so the // in-process tool catalogue has to go with them. crate::mcp::invalidate_tools_cache(&state.mcp_tools_cache).await; crate::mcp::drop_all_sessions(); state.approvals.revoke_all(); + state.indexer.wake(); // Best-effort: if the user never had a key we silently ignore the // NoEntry branch inside `clear_openai_key`. A hard failure here // (keyring daemon dead, etc.) shouldn't undo the DB wipe. Both are @@ -3444,6 +4235,42 @@ mod tests { use super::*; use serde_json::json; + fn base_url_rows(settings: &[(String, String)]) -> Vec<&str> { + settings + .iter() + .filter(|(k, _)| k == OPENAI_BASE_URL_KEY) + .map(|(_, v)| v.as_str()) + .collect() + } + + #[test] + fn import_keeps_the_base_url_a_stored_key_belongs_to() { + let row = |v: &str| (OPENAI_BASE_URL_KEY.to_string(), v.to_string()); + let theme = ("theme".to_string(), "dark".to_string()); + + // A backup naming another server: the installed URL stays. + let mut s = vec![theme.clone(), row("https://attacker.example/v1")]; + assert!(keep_current_openai_base_url(&mut s, Some("https://openrouter.ai/api/v1".into()))); + assert_eq!(base_url_rows(&s), ["https://openrouter.ai/api/v1"]); + assert!(s.contains(&theme)); + + // Installed URL is the default (no row): the imported one is dropped. + let mut s = vec![row("https://attacker.example/v1")]; + assert!(keep_current_openai_base_url(&mut s, None)); + assert!(base_url_rows(&s).is_empty()); + + // Backup without the row, installed URL custom: restore would wipe it + // back to the default, so it's carried over. + let mut s = vec![theme.clone()]; + assert!(keep_current_openai_base_url(&mut s, Some("http://localhost:1234/v1".into()))); + assert_eq!(base_url_rows(&s), ["http://localhost:1234/v1"]); + + // Same URL: nothing to report. + let mut s = vec![row("https://api.openai.com/v1")]; + assert!(!keep_current_openai_base_url(&mut s, Some("https://api.openai.com/v1".into()))); + assert_eq!(base_url_rows(&s), ["https://api.openai.com/v1"]); + } + fn stdio_input(command: &str, args: &[&str], env: &[(&str, &str)]) -> McpServerInput { serde_json::from_value(json!({ "name": "fs", @@ -3455,6 +4282,55 @@ mod tests { .expect("input") } + #[test] + fn library_text_is_capped_on_char_boundaries() { + let (short, cut) = cap_library_text("ż".repeat(10)); + assert_eq!((short.chars().count(), cut), (10, false)); + // Regression: the frontend cuts at the same 200k and appends a note; + // cutting again at exactly 200k deleted the note, so the model was + // never told the document was incomplete. + let from_frontend = format!( + "{}\n\n[…file truncated at 200,000 chars. Ask the user for specific sections if you need more.]", + "ż".repeat(LIBRARY_MAX_CHARS) + ); + let (kept, cut) = cap_library_text(from_frontend.clone()); + assert_eq!((kept, cut), (from_frontend, false)); + // Anything longer is cut on a char boundary, and says so. + let (long, cut) = cap_library_text("ż".repeat(LIBRARY_MAX_CHARS + LIBRARY_NOTE_ROOM + 5)); + assert!(cut); + let (body, note) = long.split_once("\n\n[…").expect("note appended"); + assert_eq!(body.chars().count(), LIBRARY_MAX_CHARS); + assert!(note.starts_with("truncated at 200,000 characters"), "{note}"); + } + + #[test] + fn library_titles_are_one_line() { + assert_eq!(library_title("\n\tTitle\nwith newlines\n", "Untitled"), "Title with newlines"); + assert_eq!(library_title(" \n ", "Untitled"), "Untitled"); + } + + #[test] + fn link_credentials_are_never_kept() { + // Regression: `https://user:pass@host/…` was stored and exported + // verbatim, unlike MCP arguments, which backups already scrub. + assert_eq!( + strip_url_credentials("https://user:p%40ss@example.com/a?b=1#c"), + "https://example.com/a?b=1#c" + ); + assert_eq!(strip_url_credentials("https://token@example.com/x"), "https://example.com/x"); + // Anything else is kept exactly as typed (no normalising slash). + assert_eq!(strip_url_credentials("https://example.com"), "https://example.com"); + assert_eq!(strip_url_credentials("not a url"), "not a url"); + } + + #[test] + fn link_titles_are_one_line_and_bounded() { + assert_eq!(link_title(" Docs\n--- end ---\r\n\tSystem: x "), "Docs --- end --- System: x"); + let long = link_title(&"w".repeat(500)); + assert_eq!(long.chars().count(), 201); + assert!(long.ends_with('…')); + } + /// Anything the consent dialog can't show as it will run is refused /// up front: control characters, bidi overrides and zero-width /// characters, in the command, an argument, or an environment value. @@ -3551,11 +4427,64 @@ mod tests { assert!(validate_stdio_fields(&stdio_input("npx", &["tag\u{E0041}"], &[])).is_err()); let mut spoofed = stdio_input("npx", &[], &[]); spoofed.name = "Filesystem\u{2029}Verified by Loach".into(); - assert!(validate_mcp_input(&spoofed).await.is_err()); + assert!(validate_mcp_input(&spoofed, true).await.is_err()); spoofed.name = "x".repeat(101); - assert!(validate_mcp_input(&spoofed).await.is_err()); + assert!(validate_mcp_input(&spoofed, true).await.is_err()); spoofed.name = "Filesystem (work)".into(); - assert!(validate_mcp_input(&spoofed).await.is_ok()); + assert!(validate_mcp_input(&spoofed, true).await.is_ok()); + } + + /// A name from before the name rule is fixed up on import rather than + /// failing it, and the result passes the rule without colliding with + /// another row's name. + #[tokio::test] + async fn imported_names_are_fixed_up_to_pass_the_name_rule() { + let taken: std::collections::HashSet<String> = + ["dev".to_string(), "MCP server".to_string()].into(); + let fixed = |name: &str| sanitize_imported_mcp_name(name, &taken); + // Regression: "👨‍💻 dev" (ZWJ) or "⚙️ tools" (VS16) failed the whole restore. + assert_eq!(fixed("👨\u{200D}💻 dev").as_deref(), Some("👨💻 dev")); + assert_eq!(fixed("⚙\u{FE0F} tools").as_deref(), Some("⚙ tools")); + // Stripped down to an existing name, or to nothing: numbered. + assert_eq!(fixed("dev\u{200B}").as_deref(), Some("dev (2)")); + assert_eq!(fixed(" \u{200D} ").as_deref(), Some("MCP server (2)")); + // Too long: cut, leaving room for a suffix when one is needed. + assert_eq!(fixed(&"x".repeat(150)).unwrap().chars().count(), MAX_MCP_NAME_CHARS); + // Names that already pass are left exactly as they are. + assert_eq!(fixed("Filesystem (work)"), None); + assert_eq!(fixed("dev"), None); + + let mut input = stdio_input("npx", &[], &[]); + for name in ["👨\u{200D}💻 dev", "\u{200B}", &"ż\u{200F}".repeat(80)] { + input.name = fixed(name).unwrap(); + assert!(validate_mcp_input(&input, true).await.is_ok(), "{:?}", input.name); + } + } + + /// A disabled HTTP row isn't DNS-screened, so it can be switched off or + /// renamed while its host doesn't resolve — but it still gets every + /// structural check, and an enabled one is still screened. + #[tokio::test] + async fn disabled_rows_skip_only_the_host_screen() { + let mut input: McpServerInput = serde_json::from_value(json!({ + "name": "vpn-only", + "url": "https://mcp.loach-test.invalid/mcp", + "enabled": false, + })) + .unwrap(); + assert!(validate_mcp_input(&input, false).await.unwrap().is_empty()); + assert!(matches!( + validate_mcp_input(&input, true).await, + Err(McpImportRejection::Unscreenable(_)) + )); + input.headers = Some([("Bad Name".to_string(), "x".to_string())].into()); + assert!(matches!( + validate_mcp_input(&input, false).await, + Err(McpImportRejection::Rejected(_)) + )); + input.headers = None; + input.url = "ftp://example.com".into(); + assert!(validate_mcp_input(&input, false).await.is_err()); } /// Only a click on "Start server" starts anything — not Enter (which @@ -3576,4 +4505,76 @@ mod tests { let err = stdio_consent_text(&stdio_input("npx", &[&long], &[]).to_draft()).unwrap_err(); assert!(err.contains("too long"), "{err}"); } + + /// The consent dialog guards what *would run*: a new enabled row, a new + /// command line, and — the import path — enabling a row that was off. + /// Renames and switching a row off never ask. + #[test] + fn save_asks_for_consent_only_when_what_runs_changes() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let db = crate::db::Database::open(&dir.path().join("loach.db")).expect("open"); + db.migrate().expect("migrate"); + + let fresh = stdio_input("npx", &["-y", "server-a"], &[]).to_draft(); + assert!(save_changes_what_runs(&db, &fresh), "a new enabled row"); + assert!(!save_changes_what_runs(&db, &McpServer { enabled: false, ..fresh.clone() })); + + // Stored disabled, as a snapshot import parks it. + let parked = db + .upsert_mcp_server(&McpServer { enabled: false, ..fresh.clone() }) + .expect("save"); + assert!(save_changes_what_runs(&db, &McpServer { enabled: true, ..parked.clone() })); + + let running = db + .upsert_mcp_server(&McpServer { enabled: true, ..parked.clone() }) + .expect("enable"); + assert!(!save_changes_what_runs(&db, &McpServer { name: "renamed".into(), ..running.clone() })); + assert!(!save_changes_what_runs(&db, &McpServer { enabled: false, ..running.clone() })); + let other = McpServer { + args_json: Some(r#"["-y","server-b"]"#.into()), + ..running.clone() + }; + assert!(save_changes_what_runs(&db, &other), "a new command line"); + + // A row nothing knows about is treated as new. + assert!(save_changes_what_runs(&db, &McpServer { id: "gone".into(), ..running })); + } + + /// The CRLF-injection guard on MCP headers: names are RFC 7230 tokens, + /// values visible ASCII plus space and tab, and neither may break a line. + #[test] + fn header_names_and_values_cannot_inject_lines() { + for ok in ["Authorization", "X-API-Key", "x_custom.v2", "!#$%&'*+-.^_`|~"] { + assert!(is_valid_header_name(ok), "{ok:?}"); + } + for bad in ["", "Bad Name", "X-Key:", "X\r\nInjected", "X\nY", "Ключ", "a\tb"] { + assert!(!is_valid_header_name(bad), "{bad:?}"); + } + for ok in ["", "Bearer abc.def", "a\tb", "~!@#$%^&*()"] { + assert!(is_valid_header_value(ok), "{ok:?}"); + } + for bad in ["x\r\nX-Injected: 1", "x\ny", "x\rx", "nul\0", "żółw", "del\u{7f}"] { + assert!(!is_valid_header_value(bad), "{bad:?}"); + } + assert!(is_valid_header_value(&"v".repeat(4096))); + assert!(!is_valid_header_value(&"v".repeat(4097))); + } + + /// Office Open XML names XML in its type but is a zip archive, so a link + /// to a .docx/.xlsx/.pptx is refused like any binary; real XML isn't. + #[test] + fn office_documents_are_not_textual() { + for ok in ["", "text/html; charset=utf-8", "application/json", "application/xml", "application/rss+xml", "text/plain"] { + assert!(is_textual_content_type(ok), "{ok:?}"); + } + for bad in [ + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/pdf", + "image/png", + ] { + assert!(!is_textual_content_type(bad), "{bad:?}"); + } + } } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 31be0b6..d622e42 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -30,6 +30,8 @@ const BUSY_TIMEOUT: Duration = Duration::from_secs(5); use serde::{Deserialize, Serialize}; use uuid::Uuid; +pub mod library; + /// Pragmas applied to BOTH the writer connection and every pooled reader. /// Skipped: `journal_mode = WAL` (persisted in the file header — only the /// writer needs to set it, readers inherit) and `foreign_keys` (only @@ -89,6 +91,16 @@ pub struct Session { /// export shouldn't hand their path to your model. #[serde(default)] pub workspace_root: Option<String>, + /// How this chat's Library and Space sources reach the model: `"auto"`, + /// `"full"` or `"retrieval"`. Null means "inherit" — the Space's mode + /// for a chat in a Space, otherwise `"auto"`. See `src/lib/sources.ts`. + #[serde(default)] + pub retrieval_mode: Option<String>, + /// How images are scaled before they reach the model, as the JSON + /// [`normalize_image_scaling`] accepts. Null means "inherit" — the + /// Space's, else the app's. See `src/lib/imageScaling.ts`. + #[serde(default)] + pub image_scaling_json: Option<String>, pub created_at: i64, pub updated_at: i64, } @@ -132,6 +144,16 @@ pub struct Space { /// on (matching the behaviour an existing user would see post-migration). #[serde(default = "default_true")] pub memory_enabled: bool, + /// How the Space's sources reach the model: `"auto"` (full text while + /// they fit, retrieved excerpts once they don't), `"full"` or + /// `"retrieval"`. Defaults to `"full"` on exports that predate it — what + /// those Spaces did, as the 1.6 migration keeps for existing rows. + #[serde(default = "pre_library_retrieval_mode")] + pub retrieval_mode: String, + /// How images are scaled for the Space's chats, as the JSON + /// [`normalize_image_scaling`] accepts. Null means "inherit the app's". + #[serde(default)] + pub image_scaling_json: Option<String>, pub created_at: i64, pub updated_at: i64, } @@ -140,6 +162,43 @@ fn default_true() -> bool { true } +fn default_retrieval_mode() -> String { + "auto".to_string() +} + +fn pre_library_retrieval_mode() -> String { + "full".to_string() +} + +/// The values a `retrieval_mode` column may hold. The Space page looks its +/// labels up by the value, so anything else breaks it. +pub fn is_retrieval_mode(mode: &str) -> bool { + matches!(mode, "auto" | "full" | "retrieval") +} + +/// An image-scaling override (`src/lib/imageScaling.ts`) re-encoded, or +/// None when `json` isn't one: unparseable, an unknown mode, or a size +/// outside what the UI offers. +pub fn normalize_image_scaling(json: &str) -> Option<String> { + #[derive(Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + struct ImageScaling { + enabled: bool, + mode: String, + px: u32, + percent: u32, + } + let s: ImageScaling = serde_json::from_str(json).ok()?; + let valid = matches!(s.mode.as_str(), "px" | "percent") + && (64..=8192).contains(&s.px) + && (1..=100).contains(&s.percent); + if valid { + serde_json::to_string(&s).ok() + } else { + None + } +} + /// One free-text fact persisted across chats in a Space. The extractor /// proposes new rows after each assistant turn; users can edit, delete, or /// pin entries from the Memory tab. `source_session_id` / `source_message_id` @@ -182,6 +241,10 @@ pub struct SpaceFile { pub data: String, pub size: i64, pub position: i32, + /// `false` keeps the source in the Space but out of every chat's + /// context. Defaults to on for exports that predate the toggle. + #[serde(default = "default_true")] + pub enabled: bool, pub created_at: i64, } @@ -429,12 +492,18 @@ pub struct StorageStats { pub space_files: i64, pub snippets: i64, pub mcp_servers: i64, + #[serde(default)] + pub library_items: i64, /// Logical text bytes, by area. These ignore page overhead and indexes, /// so they sum to less than `db_bytes`. pub message_bytes: i64, pub attachment_bytes: i64, pub space_bytes: i64, + /// Library item bodies plus the retrieval index built over them and + /// over Space sources (chunk text + embeddings). + #[serde(default)] + pub library_bytes: i64, pub other_bytes: i64, } @@ -521,11 +590,6 @@ impl Database { }) } - /// Path to the SQLite file backing this handle. - pub fn path(&self) -> &Path { - &self.path - } - /// Run a read-only closure against a pooled connection. Use this for /// SELECT queries that don't need to coordinate with a write. The /// closure must not mutate the database — pool connections share the @@ -910,6 +974,170 @@ impl Database { "#, )?; + // Library (v1.6): how sources reach the model, per Space and per chat + // (NULL on a chat = inherit), and the per-source on/off switch for a + // Space's own uploads. Spaces that exist when the column arrives get + // "full" — what every Space did before 1.6: inline every file, at any + // size. "auto" (the default for new Spaces) would switch an existing + // Space whose files pass half the window to excerpts without the user + // ever choosing it. Uploads land enabled. + if !has_column(&conn, "spaces", "retrieval_mode")? { + conn.execute_batch( + "BEGIN; + ALTER TABLE spaces ADD COLUMN retrieval_mode TEXT NOT NULL DEFAULT 'auto'; + UPDATE spaces SET retrieval_mode = 'full'; + COMMIT;", + )?; + } + if !has_column(&conn, "sessions", "retrieval_mode")? { + conn.execute_batch("ALTER TABLE sessions ADD COLUMN retrieval_mode TEXT;")?; + } + if !has_column(&conn, "space_files", "enabled")? { + conn.execute_batch( + "ALTER TABLE space_files ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;", + )?; + } + // Image scaling, per chat and per Space (NULL = inherit). + if !has_column(&conn, "sessions", "image_scaling_json")? { + conn.execute_batch("ALTER TABLE sessions ADD COLUMN image_scaling_json TEXT;")?; + } + if !has_column(&conn, "spaces", "image_scaling_json")? { + conn.execute_batch("ALTER TABLE spaces ADD COLUMN image_scaling_json TEXT;")?; + } + + // Library: items (texts, files, links) stored once, grouped into + // datasets (an item can sit in several), and linked — not copied — + // into Spaces and chats. A link row points at exactly one of an item + // or a dataset; the partial unique indexes stop the same thing being + // linked twice to one owner. + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS library_items ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + title TEXT NOT NULL, + mime TEXT NOT NULL DEFAULT '', + media TEXT NOT NULL DEFAULT 'text', + content TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + url TEXT, + fetched_at INTEGER, + content_hash TEXT NOT NULL DEFAULT '', + truncated INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_library_items_hash + ON library_items(content_hash); + CREATE TABLE IF NOT EXISTS library_datasets ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS library_dataset_items ( + dataset_id TEXT NOT NULL REFERENCES library_datasets(id) ON DELETE CASCADE, + item_id TEXT NOT NULL REFERENCES library_items(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (dataset_id, item_id) + ); + CREATE INDEX IF NOT EXISTS idx_library_dataset_items_item + ON library_dataset_items(item_id); + CREATE TABLE IF NOT EXISTS space_library_sources ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + item_id TEXT REFERENCES library_items(id) ON DELETE CASCADE, + dataset_id TEXT REFERENCES library_datasets(id) ON DELETE CASCADE, + enabled INTEGER NOT NULL DEFAULT 1, + position INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_space_library_sources_item + ON space_library_sources(owner_id, item_id) WHERE item_id IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_space_library_sources_dataset + ON space_library_sources(owner_id, dataset_id) WHERE dataset_id IS NOT NULL; + CREATE TABLE IF NOT EXISTS session_library_sources ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + item_id TEXT REFERENCES library_items(id) ON DELETE CASCADE, + dataset_id TEXT REFERENCES library_datasets(id) ON DELETE CASCADE, + enabled INTEGER NOT NULL DEFAULT 1, + position INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_session_library_sources_item + ON session_library_sources(owner_id, item_id) WHERE item_id IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_session_library_sources_dataset + ON session_library_sources(owner_id, dataset_id) WHERE dataset_id IS NOT NULL; + "#, + )?; + + // Retrieval index over every text source — Library items + // (`doc_kind = 'library'`) and a Space's own uploads + // (`'space_file'`). Derived data: never exported, rebuilt by the + // indexer from the documents themselves. `rag_chunks_fts` is an + // external-content FTS5 index kept in step by triggers; `embedding` + // is a unit-length f32 vector (little-endian) from `embed_model`, + // NULL until the background indexer gets to it. The polymorphic + // `doc_id` can't carry a cascading FK, so the two delete triggers do + // that job — they also fire for rows removed by a Space's cascade. + // + // Ids are AUTOINCREMENT so a deleted chunk's id is never handed to a + // new one: the indexer stores vectors by id after a slow request, and + // a reused id would put an old text's vector on a new text. Pre-1.6 + // development builds created the table without it; being derived + // data, that table is dropped and rebuilt (the indexer re-chunks + // every document on its first pass). + let legacy_rag: i64 = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name = 'rag_chunks' AND sql NOT LIKE '%AUTOINCREMENT%'", + [], + |r| r.get(0), + )?; + if legacy_rag > 0 { + conn.execute_batch("DROP TABLE IF EXISTS rag_chunks_fts; DROP TABLE rag_chunks;")?; + } + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS rag_chunks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + doc_kind TEXT NOT NULL, + doc_id TEXT NOT NULL, + seq INTEGER NOT NULL, + text TEXT NOT NULL, + embedding BLOB, + embed_model TEXT + ); + CREATE INDEX IF NOT EXISTS idx_rag_chunks_doc + ON rag_chunks(doc_kind, doc_id, seq); + -- Covers the per-document "embedded with this model" counts, + -- which otherwise read each row past its embedding BLOB to + -- reach `embed_model`. + CREATE INDEX IF NOT EXISTS idx_rag_chunks_doc_model + ON rag_chunks(doc_kind, doc_id, embed_model); + CREATE VIRTUAL TABLE IF NOT EXISTS rag_chunks_fts USING fts5( + text, + content = 'rag_chunks', + content_rowid = 'id', + tokenize = 'unicode61 remove_diacritics 2' + ); + CREATE TRIGGER IF NOT EXISTS rag_chunks_ai AFTER INSERT ON rag_chunks BEGIN + INSERT INTO rag_chunks_fts(rowid, text) VALUES (new.id, new.text); + END; + CREATE TRIGGER IF NOT EXISTS rag_chunks_ad AFTER DELETE ON rag_chunks BEGIN + INSERT INTO rag_chunks_fts(rag_chunks_fts, rowid, text) + VALUES ('delete', old.id, old.text); + END; + CREATE TRIGGER IF NOT EXISTS library_items_rag_ad AFTER DELETE ON library_items BEGIN + DELETE FROM rag_chunks WHERE doc_kind = 'library' AND doc_id = old.id; + END; + CREATE TRIGGER IF NOT EXISTS space_files_rag_ad AFTER DELETE ON space_files BEGIN + DELETE FROM rag_chunks WHERE doc_kind = 'space_file' AND doc_id = old.id; + END; + "#, + )?; + Ok(()) } @@ -918,7 +1146,7 @@ impl Database { pub fn list_sessions(&self) -> Result<Vec<Session>> { self.with_read(|conn| { let mut stmt = conn.prepare( - "SELECT id, title, provider, model, system_prompt, params_json, space_id, pinned_at, archived_at, forked_from_session_id, label, folder_id, workspace_root, created_at, updated_at + "SELECT id, title, provider, model, system_prompt, params_json, space_id, pinned_at, archived_at, forked_from_session_id, label, folder_id, workspace_root, created_at, updated_at, retrieval_mode, image_scaling_json FROM sessions ORDER BY updated_at DESC", )?; let rows = stmt @@ -937,6 +1165,8 @@ impl Database { label: r.get(10)?, folder_id: r.get(11)?, workspace_root: r.get(12)?, + retrieval_mode: r.get(15)?, + image_scaling_json: r.get(16)?, created_at: r.get(13)?, updated_at: r.get(14)?, }) @@ -976,6 +1206,8 @@ impl Database { label: None, folder_id: None, workspace_root: None, + retrieval_mode: None, + image_scaling_json: None, created_at: now, updated_at: now, }) @@ -984,7 +1216,9 @@ impl Database { /// Create a new session as a fork of `source_id`, copying the source's /// title / provider / model / system prompt / params / space verbatim, /// and duplicating its messages up to (and including) `up_to_message_id` - /// — or every message in the source when that arg is None. + /// — or every message in the source when that arg is None. The one + /// exception to "verbatim" is a compaction summary that covers turns + /// after the cut (see below). /// /// Message copies get fresh ids and a fresh session_id but keep the /// source's `created_at` so transcript ordering matches the original. @@ -1017,6 +1251,18 @@ impl Database { } }; let to_copy = &messages[..take_until_idx]; + // A compaction summary in the prompt stands in for every compacted + // turn. When the cut falls before the last of them, the fork would + // inherit a summary of turns it doesn't contain — so it drops the + // summary and sends the turns it does copy in full again. + let drop_summary = messages[take_until_idx..] + .iter() + .any(|m| m.compacted_at.is_some()); + let system_prompt = if drop_summary { + source.system_prompt.as_deref().map(strip_summary_block) + } else { + source.system_prompt.clone() + }; let new_id = Uuid::new_v4().to_string(); let now = Utc::now().timestamp_millis(); @@ -1033,22 +1279,33 @@ impl Database { tx.execute( "INSERT INTO sessions (id, title, provider, model, system_prompt, params_json, space_id, pinned_at, archived_at, forked_from_session_id, - folder_id, workspace_root, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, NULL, ?8, ?9, ?10, ?11, ?11)", + folder_id, workspace_root, retrieval_mode, image_scaling_json, + created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, NULL, ?8, ?9, ?10, ?11, ?12, ?13, ?13)", params![ new_id, source.title, source.provider, source.model, - source.system_prompt, + system_prompt, source.params_json, source.space_id, source.id, source.folder_id, source.workspace_root, + source.retrieval_mode, + source.image_scaling_json, now, ], )?; + // The chat's Library sources come along too: a branch continues the + // same conversation, so it should be looking at the same material. + tx.execute( + "INSERT INTO session_library_sources (id, owner_id, item_id, dataset_id, enabled, position, created_at) + SELECT lower(hex(randomblob(16))), ?1, item_id, dataset_id, enabled, position, ?2 + FROM session_library_sources WHERE owner_id = ?3", + params![new_id, now, source.id], + )?; // One prepare for the whole copy — forking a long chat replays every // message, and `tx.execute` would re-parse the INSERT each time. @@ -1069,7 +1326,7 @@ impl Database { m.attachments_json, m.metrics_json, m.tool_calls_json, - m.compacted_at, + if drop_summary { None } else { m.compacted_at }, m.created_at, m.import_group, m.import_hidden, @@ -1085,7 +1342,7 @@ impl Database { title: source.title, provider: source.provider, model: source.model, - system_prompt: source.system_prompt, + system_prompt, params_json: source.params_json, space_id: source.space_id, pinned_at: None, @@ -1104,6 +1361,8 @@ impl Database { // of work, and the whole point of branching a coding chat is to // try something else against the same project. workspace_root: source.workspace_root, + retrieval_mode: source.retrieval_mode, + image_scaling_json: source.image_scaling_json, created_at: now, updated_at: now, }) @@ -1170,15 +1429,23 @@ impl Database { pub fn pin_session(&self, id: &str, pinned: bool) -> Result<()> { let conn = self.conn.lock(); let now = Utc::now().timestamp_millis(); - let pinned_at: Option<i64> = if pinned { Some(now) } else { None }; - // Bump `updated_at` too so the sidebar's `ORDER BY updated_at DESC` - // surfaces the just-pinned chat to the top. Without this, pinning - // a buried chat leaves it visually unchanged even though the - // pinned-icon flipped on. - conn.execute( - "UPDATE sessions SET pinned_at = ?1, updated_at = ?2 WHERE id = ?3", - params![pinned_at, now, id], - )?; + // Pinning bumps `updated_at` too so the sidebar's `ORDER BY + // updated_at DESC` surfaces the just-pinned chat to the top. Without + // this, pinning a buried chat leaves it visually unchanged even + // though the pinned-icon flipped on. Unpinning leaves it alone — the + // chat drops back to its own date group instead of jumping to + // "Today". + if pinned { + conn.execute( + "UPDATE sessions SET pinned_at = ?1, updated_at = ?1 WHERE id = ?2", + params![now, id], + )?; + } else { + conn.execute( + "UPDATE sessions SET pinned_at = NULL WHERE id = ?1", + params![id], + )?; + } Ok(()) } @@ -1223,6 +1490,19 @@ impl Database { Ok(()) } + /// Move the chat into the Space `space_id`, or out of any Space with + /// `None`. Like `set_session_folder` this leaves `updated_at` alone — + /// moving a chat isn't new activity in it. An unknown Space id fails on + /// the foreign key instead of leaving the chat pointing at nothing. + pub fn set_session_space(&self, id: &str, space_id: Option<&str>) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE sessions SET space_id = ?1 WHERE id = ?2", + params![space_id, id], + )?; + Ok(()) + } + pub fn archive_session(&self, id: &str, archived: bool) -> Result<()> { let conn = self.conn.lock(); let now = Utc::now().timestamp_millis(); @@ -1250,7 +1530,7 @@ impl Database { pub fn get_session(&self, id: &str) -> Result<Option<Session>> { self.with_read(|conn| { let mut stmt = conn.prepare( - "SELECT id, title, provider, model, system_prompt, params_json, space_id, pinned_at, archived_at, forked_from_session_id, label, folder_id, workspace_root, created_at, updated_at + "SELECT id, title, provider, model, system_prompt, params_json, space_id, pinned_at, archived_at, forked_from_session_id, label, folder_id, workspace_root, created_at, updated_at, retrieval_mode, image_scaling_json FROM sessions WHERE id = ?1", )?; let mut rows = stmt.query(params![id])?; @@ -1269,6 +1549,8 @@ impl Database { label: r.get(10)?, folder_id: r.get(11)?, workspace_root: r.get(12)?, + retrieval_mode: r.get(15)?, + image_scaling_json: r.get(16)?, created_at: r.get(13)?, updated_at: r.get(14)?, })) @@ -1514,9 +1796,16 @@ impl Database { space_files: scalar("SELECT COUNT(*) FROM space_files")?, snippets: scalar("SELECT COUNT(*) FROM snippets")?, mcp_servers: scalar("SELECT COUNT(*) FROM mcp_servers")?, + library_items: scalar("SELECT COUNT(*) FROM library_items")?, + // The transcript as stored: the reply text plus its reasoning, + // tool-call records (results included) and per-turn metrics. message_bytes: scalar( - "SELECT COALESCE(SUM(LENGTH(CAST(content AS BLOB))), 0) FROM messages", + "SELECT COALESCE(SUM(LENGTH(CAST(content AS BLOB)) + + LENGTH(CAST(COALESCE(thinking, '') AS BLOB)) + + LENGTH(CAST(COALESCE(tool_calls_json, '') AS BLOB)) + + LENGTH(CAST(COALESCE(metrics_json, '') AS BLOB))), 0) + FROM messages", )?, // Attachments are inlined as base64 into the owning row — // there's no separate file store — so they're the bucket @@ -1534,6 +1823,12 @@ impl Database { "SELECT COALESCE(SUM(LENGTH(CAST(description AS BLOB)) + LENGTH(CAST(instructions AS BLOB))), 0) FROM spaces", )?, + library_bytes: scalar( + "SELECT COALESCE(SUM(LENGTH(CAST(content AS BLOB))), 0) FROM library_items", + )? + scalar( + "SELECT COALESCE(SUM(LENGTH(CAST(text AS BLOB)) + + COALESCE(LENGTH(embedding), 0)), 0) FROM rag_chunks", + )?, // Everything else worth counting: prompt text, settings, and // the per-chat metadata. Deliberately not exhaustive over // every column — this is a rough "and the rest" figure, so a @@ -1848,7 +2143,7 @@ impl Database { let mut stmt = conn.prepare( "SELECT id, name, description, instructions, default_provider, default_model, default_params_json, - memory_enabled, created_at, updated_at + memory_enabled, created_at, updated_at, retrieval_mode, image_scaling_json FROM spaces ORDER BY updated_at DESC", )?; let rows = stmt @@ -1862,6 +2157,8 @@ impl Database { default_model: r.get(5)?, default_params_json: r.get(6)?, memory_enabled: r.get::<_, i64>(7)? != 0, + retrieval_mode: r.get(10)?, + image_scaling_json: r.get(11)?, created_at: r.get(8)?, updated_at: r.get(9)?, }) @@ -1876,7 +2173,7 @@ impl Database { let mut stmt = conn.prepare( "SELECT id, name, description, instructions, default_provider, default_model, default_params_json, - memory_enabled, created_at, updated_at + memory_enabled, created_at, updated_at, retrieval_mode, image_scaling_json FROM spaces WHERE id = ?1", )?; let mut rows = stmt.query(params![id])?; @@ -1890,6 +2187,8 @@ impl Database { default_model: r.get(5)?, default_params_json: r.get(6)?, memory_enabled: r.get::<_, i64>(7)? != 0, + retrieval_mode: r.get(10)?, + image_scaling_json: r.get(11)?, created_at: r.get(8)?, updated_at: r.get(9)?, })) @@ -1922,6 +2221,8 @@ impl Database { default_model: None, default_params_json: None, memory_enabled: true, + retrieval_mode: default_retrieval_mode(), + image_scaling_json: None, created_at: now, updated_at: now, }) @@ -1981,7 +2282,7 @@ impl Database { pub fn list_space_files(&self, space_id: &str) -> Result<Vec<SpaceFile>> { self.with_read(|conn| { let mut stmt = conn.prepare( - "SELECT id, space_id, name, mime, kind, data, size, position, created_at + "SELECT id, space_id, name, mime, kind, data, size, position, created_at, enabled FROM space_files WHERE space_id = ?1 ORDER BY position ASC", )?; let rows = stmt @@ -1995,6 +2296,7 @@ impl Database { data: r.get(5)?, size: r.get(6)?, position: r.get(7)?, + enabled: r.get::<_, i64>(9)? != 0, created_at: r.get(8)?, }) })? @@ -2019,12 +2321,19 @@ impl Database { ) -> Result<SpaceFile> { let id = Uuid::new_v4().to_string(); let now = Utc::now().timestamp_millis(); - let conn = self.conn.lock(); - conn.execute( + let mut conn = self.conn.lock(); + // Row and retrieval chunks in one transaction, so a text source is + // keyword-searchable the moment it exists. + let tx = conn.transaction()?; + tx.execute( "INSERT INTO space_files (id, space_id, name, mime, kind, data, size, position, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![id, space_id, name, mime, kind, data, size, position, now], )?; + if kind == "text" { + library::replace_chunks(&tx, library::DOC_SPACE_FILE, &id, data)?; + } + tx.commit()?; Ok(SpaceFile { id, space_id: space_id.to_string(), @@ -2034,10 +2343,67 @@ impl Database { data: data.to_string(), size, position, + enabled: true, created_at: now, }) } + /// Switch one of a Space's own uploads in or out of its chats' context + /// without removing it. + pub fn set_space_file_enabled(&self, file_id: &str, enabled: bool) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE space_files SET enabled = ?1 WHERE id = ?2", + params![enabled as i64, file_id], + )?; + Ok(()) + } + + /// Set how a Space's sources reach the model (`auto` / `full` / + /// `retrieval`). Validated by the caller. + pub fn set_space_retrieval_mode(&self, id: &str, mode: &str) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE spaces SET retrieval_mode = ?1 WHERE id = ?2", + params![mode, id], + )?; + Ok(()) + } + + /// Set a chat's own retrieval mode, or `None` to inherit its Space's. + /// Leaves `updated_at` alone, like the label: it's a setting, not + /// activity. + pub fn set_session_retrieval_mode(&self, id: &str, mode: Option<&str>) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE sessions SET retrieval_mode = ?1 WHERE id = ?2", + params![mode, id], + )?; + Ok(()) + } + + /// Set a Space's image scaling, or `None` to inherit the app's. + /// Validated by the caller. + pub fn set_space_image_scaling(&self, id: &str, json: Option<&str>) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE spaces SET image_scaling_json = ?1 WHERE id = ?2", + params![json, id], + )?; + Ok(()) + } + + /// Set a chat's own image scaling, or `None` to inherit. Leaves + /// `updated_at` alone, like the retrieval mode. + pub fn set_session_image_scaling(&self, id: &str, json: Option<&str>) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE sessions SET image_scaling_json = ?1 WHERE id = ?2", + params![json, id], + )?; + Ok(()) + } + pub fn remove_space_file(&self, file_id: &str) -> Result<()> { let conn = self.conn.lock(); conn.execute("DELETE FROM space_files WHERE id = ?1", params![file_id])?; @@ -2656,7 +3022,7 @@ impl Database { pub fn all_space_files(&self) -> Result<Vec<SpaceFile>> { let conn = self.read_pool.get().context("acquire read connection")?; let mut stmt = conn.prepare( - "SELECT id, space_id, name, mime, kind, data, size, position, created_at + "SELECT id, space_id, name, mime, kind, data, size, position, created_at, enabled FROM space_files ORDER BY space_id, position", )?; let rows = stmt @@ -2670,6 +3036,7 @@ impl Database { data: r.get(5)?, size: r.get(6)?, position: r.get(7)?, + enabled: r.get::<_, i64>(9)? != 0, created_at: r.get(8)?, }) })? @@ -2696,8 +3063,8 @@ impl Database { // eleven separate reads, and with the lock released between them a // streaming flush or a session create could commit halfway through — // producing a file whose `messages` reference a session absent from - // `sessions`. FK enforcement is off during restore, so those orphans - // then import silently as rows nothing displays. + // `sessions`. A restore drops such orphans (`clear_dangling_refs`), so + // they'd be silently lost on the way back in. // // Every read below goes through the pool, and no pooled read takes // this lock, so holding it here freezes the database without @@ -2718,6 +3085,9 @@ impl Database { // `postgresql://user:pass@host/db` as its one argument) or // after a `--token` doesn't. s.args_json = s.args_json.as_deref().and_then(redact_args_json); + // An HTTP server's URL can carry its credentials too: a token + // as the userinfo or as a query parameter. + s.url = redact_http_url(&s.url); s }) .collect(); @@ -2736,7 +3106,7 @@ impl Database { }) .collect(); Ok(DatabaseSnapshot { - schema: "loach/v1".to_string(), + schema: SNAPSHOT_SCHEMA.to_string(), exported_at: Utc::now().timestamp_millis(), loach_version: env!("CARGO_PKG_VERSION").to_string(), data: SnapshotData { @@ -2752,6 +3122,10 @@ impl Database { snippet_fill_values: self.all_snippet_fill_values()?, mcp_servers, settings: self.all_settings()?, + library_items: self.all_library_items()?, + library_datasets: self.list_library_datasets()?, + space_library_sources: self.all_library_sources(library::SourceOwner::Space)?, + session_library_sources: self.all_library_sources(library::SourceOwner::Session)?, }, }) } @@ -2760,7 +3134,8 @@ impl Database { /// row-count breakdown for the UI to display ("Imported 12 chats, 145 /// messages, …"). FK enforcement is disabled for the duration so a /// snapshot that's missing e.g. a referenced space_id for a session - /// still imports cleanly (the orphan FK becomes NULL at next write). + /// still imports; `clear_dangling_refs` then settles every such + /// reference before the commit, the way the FK's own action would have. /// /// `PRAGMA foreign_keys = ON` is restored on every exit path — including /// errors mid-import — so a bad snapshot can't leave the shared @@ -2788,6 +3163,12 @@ impl Database { // toggled back on mid-transaction. `snippet_variables` is independent. tx.execute_batch( r#" + DELETE FROM rag_chunks; + DELETE FROM session_library_sources; + DELETE FROM space_library_sources; + DELETE FROM library_dataset_items; + DELETE FROM library_datasets; + DELETE FROM library_items; DELETE FROM messages; DELETE FROM space_files; DELETE FROM space_memories; @@ -2823,12 +3204,19 @@ impl Database { // of that, and it also means a hand-edited snapshot can't grant a // model access to a path the user never picked. Don't "complete" this // list. + // + // `retrieval_mode` is screened here because a snapshot never passes + // the setters' `check_retrieval_mode`: an unknown value goes back to + // inheriting (NULL), and an unknown Space mode below to what a new + // Space gets. `image_scaling_json` likewise goes back to inheriting + // unless it's one the setters would have taken. for s in &d.sessions { tx.execute( "INSERT INTO sessions (id, title, provider, model, system_prompt, params_json, space_id, pinned_at, archived_at, forked_from_session_id, - label, folder_id, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + label, folder_id, created_at, updated_at, retrieval_mode, + image_scaling_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", params![ s.id, s.title, @@ -2844,6 +3232,8 @@ impl Database { s.folder_id, s.created_at, s.updated_at, + s.retrieval_mode.as_deref().filter(|m| is_retrieval_mode(m)), + s.image_scaling_json.as_deref().and_then(normalize_image_scaling), ], )?; } @@ -2882,8 +3272,8 @@ impl Database { "INSERT INTO spaces (id, name, description, instructions, default_provider, default_model, default_params_json, memory_enabled, - created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + created_at, updated_at, retrieval_mode, image_scaling_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", params![ sp.id, sp.name, @@ -2895,6 +3285,12 @@ impl Database { if sp.memory_enabled { 1_i64 } else { 0_i64 }, sp.created_at, sp.updated_at, + if is_retrieval_mode(&sp.retrieval_mode) { + sp.retrieval_mode.clone() + } else { + default_retrieval_mode() + }, + sp.image_scaling_json.as_deref().and_then(normalize_image_scaling), ], )?; } @@ -2934,8 +3330,8 @@ impl Database { for f in &d.space_files { tx.execute( - "INSERT INTO space_files (id, space_id, name, mime, kind, data, size, position, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT INTO space_files (id, space_id, name, mime, kind, data, size, position, created_at, enabled) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ f.id, f.space_id, @@ -2946,6 +3342,7 @@ impl Database { f.size, f.position, f.created_at, + f.enabled as i64, ], )?; } @@ -3002,8 +3399,9 @@ impl Database { for f in &d.snippet_fill_values { // `snippet_id` references `snippets(id)`. FK is OFF for this - // transaction so an orphaned row imports cleanly — but with the - // snippets table populated above, the common case lines up. + // transaction so an orphaned row inserts cleanly (and is dropped + // by `clear_dangling_refs`) — but with the snippets table + // populated above, the common case lines up. tx.execute( "INSERT INTO snippet_fill_values (snippet_id, key, value, updated_at) VALUES (?1, ?2, ?3, ?4) @@ -3021,6 +3419,19 @@ impl Database { )?; } + // Library rows. The retrieval index (`rag_chunks`) isn't part of a + // snapshot — the indexer rebuilds it from these bodies after the + // restore, so an export never carries embeddings from a model the + // importing machine may not even have. + library::restore_library(&tx, d)?; + + let dropped = clear_dangling_refs(&tx)?; + if dropped > 0 { + tracing::warn!( + "restore: dropped {dropped} row(s) whose chat, Space or other parent the snapshot didn't carry" + ); + } + let stats = ImportStats { sessions: d.sessions.len(), folders: d.folders.len(), @@ -3034,6 +3445,8 @@ impl Database { snippet_fill_values: d.snippet_fill_values.len(), mcp_servers: d.mcp_servers.len(), settings: d.settings.len(), + library_items: d.library_items.len(), + library_datasets: d.library_datasets.len(), }; tx.commit()?; @@ -3092,6 +3505,12 @@ impl Database { // resurfaced on the next snippet run. tx.execute_batch( r#" + DELETE FROM rag_chunks; + DELETE FROM session_library_sources; + DELETE FROM space_library_sources; + DELETE FROM library_dataset_items; + DELETE FROM library_datasets; + DELETE FROM library_items; DELETE FROM messages; DELETE FROM space_files; DELETE FROM space_memories; @@ -3133,6 +3552,12 @@ impl Database { // does land back at zero rows. tx.execute_batch( r#" + DELETE FROM rag_chunks; + DELETE FROM session_library_sources; + DELETE FROM space_library_sources; + DELETE FROM library_dataset_items; + DELETE FROM library_datasets; + DELETE FROM library_items; DELETE FROM messages; DELETE FROM space_files; DELETE FROM space_memories; @@ -3152,6 +3577,52 @@ impl Database { } } +/// Settle the references a restore left dangling — FK enforcement is off +/// for it, so a snapshot naming a Space, folder or chat it doesn't carry +/// (hand-edited, or trimmed) inserts cleanly and nothing would otherwise +/// fix it: a chat pointing at a missing Space sits in no list at all. Does +/// what each FK's own action would have done had the parent been deleted: +/// the optional pointers on `sessions` go NULL (`ON DELETE SET NULL`), and +/// every row whose required parent is missing — which nothing can display +/// or ever cascade away — is dropped (`ON DELETE CASCADE`). Returns how many +/// rows were dropped. +fn clear_dangling_refs(tx: &Connection) -> Result<usize> { + tx.execute_batch( + "UPDATE sessions SET space_id = NULL + WHERE space_id IS NOT NULL AND space_id NOT IN (SELECT id FROM spaces); + UPDATE sessions SET folder_id = NULL + WHERE folder_id IS NOT NULL AND folder_id NOT IN (SELECT id FROM folders); + UPDATE sessions SET forked_from_session_id = NULL + WHERE forked_from_session_id IS NOT NULL + AND forked_from_session_id NOT IN (SELECT id FROM sessions);", + )?; + // What's left are the children of a missing required parent. No table + // here is WITHOUT ROWID, so every violation names its row. A row with two + // dangling references is listed twice; the second DELETE finds nothing. + let orphans: Vec<(String, i64)> = tx + .prepare("PRAGMA foreign_key_check")? + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::<Result<_, _>>()?; + let mut dropped = 0; + for (table, rowid) in &orphans { + dropped += + tx.execute(&format!("DELETE FROM \"{table}\" WHERE rowid = ?1"), params![rowid])?; + } + Ok(dropped) +} + +/// The wire-format tag every export is written with. v2 (1.6) added the +/// Library tables, `retrieval_mode` and `space_files.enabled`: a 1.5 build +/// accepts only v1, so the bump makes it refuse a 1.6 export instead of +/// wiping everything and restoring it without the Library. +pub const SNAPSHOT_SCHEMA: &str = "loach/v2"; + +/// Whether this build can restore an export tagged `schema`. v1 exports +/// load with the 1.6 fields at their serde defaults. +pub fn is_supported_snapshot_schema(schema: &str) -> bool { + matches!(schema, "loach/v1" | SNAPSHOT_SCHEMA) +} + #[derive(Debug, Serialize, Deserialize)] pub struct DatabaseSnapshot { /// Wire-format tag — bump when the shape changes so loaders can @@ -3191,6 +3662,19 @@ pub struct SnapshotData { /// Key/value settings as stored in the `settings` table. A plain list /// (not a map) so round-trip order is stable. pub settings: Vec<(String, String)>, + /// Library items with their full bodies. Empty on exports that predate + /// the Library. + #[serde(default)] + pub library_items: Vec<library::LibraryItem>, + /// Datasets, each carrying its ordered member ids. + #[serde(default)] + pub library_datasets: Vec<library::LibraryDataset>, + /// Library items / datasets linked into Spaces. + #[serde(default)] + pub space_library_sources: Vec<library::LibrarySource>, + /// Library items / datasets linked into chats. + #[serde(default)] + pub session_library_sources: Vec<library::LibrarySource>, } /// Row-count breakdown returned after a successful import. @@ -3208,6 +3692,8 @@ pub struct ImportStats { pub snippet_fill_values: usize, pub mcp_servers: usize, pub settings: usize, + pub library_items: usize, + pub library_datasets: usize, } /// How much text `search_messages` keeps around a hit, in characters: the @@ -3302,20 +3788,27 @@ fn build_snippet(text: &str, needle: &str) -> Option<String> { /// Stands in for a secret [`redact_args_json`] leaves out of an export. const REDACTED: &str = "REDACTED"; -/// A stdio server's `args_json` as it goes into an export: a password in a -/// URL (`scheme://user:pass@host`) and the value of a flag whose name says -/// it holds a credential (`--token x`, `--api-key=x`) are replaced with -/// [`REDACTED`]; everything else is kept as typed. A value that doesn't -/// parse as a list of strings is dropped whole — it's not a shape Loach -/// writes, so nothing in it can be told apart from a secret. +/// Whether a flag, environment variable, header or query parameter called +/// `name` holds a credential. `auth` only counts as a whole word (`--auth`, +/// `--basic-auth`), so `--author` and `--authority` keep their values. +fn is_secret_name(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + ["token", "secret", "password", "passwd", "apikey", "api-key", "api_key", "authorization"] + .iter() + .any(|w| n.contains(w)) + || n.split(['-', '_', '.']).any(|word| word == "auth") +} + +/// A stdio server's `args_json` as it goes into an export: the value of a +/// flag whose name says it holds a credential (`--token x`, `--api-key=x`), +/// of such an environment assignment or header (`-e GITHUB_TOKEN=x`, +/// `--header "Authorization: Bearer x"`), and a URL's credentials (see +/// [`redact_url_credentials`]) are replaced with [`REDACTED`]; everything +/// else is kept as typed. A value that doesn't parse as a list of strings is +/// dropped whole — it's not a shape Loach writes, so nothing in it can be +/// told apart from a secret. fn redact_args_json(json: &str) -> Option<String> { let args: Vec<String> = serde_json::from_str(json).ok()?; - let secret_flag = |name: &str| { - let n = name.to_ascii_lowercase(); - ["token", "secret", "password", "passwd", "apikey", "api-key", "api_key", "auth"] - .iter() - .any(|w| n.contains(w)) - }; let mut out = Vec::with_capacity(args.len()); let mut value_is_secret = false; for arg in args { @@ -3326,23 +3819,51 @@ fn redact_args_json(json: &str) -> Option<String> { if let Some(flag) = arg.strip_prefix('-') { let flag = flag.trim_start_matches('-'); match flag.split_once('=') { - Some((name, _)) if secret_flag(name) => { - let keep = arg.len() - flag.len() + name.len() + 1; - out.push(format!("{}{REDACTED}", &arg[..keep])); + Some((name, value)) => { + let keep = &arg[..arg.len() - flag.len() + name.len() + 1]; + if is_secret_name(name) { + out.push(format!("{keep}{REDACTED}")); + } else { + // `--env=GITHUB_TOKEN=x`, `--header=Authorization: …` + out.push(format!("{keep}{}", redact_arg_value(value))); + } continue; } - None if secret_flag(flag) => value_is_secret = true, - _ => {} + None if is_secret_name(flag) => value_is_secret = true, + None => {} } } - out.push(redact_url_password(&arg)); + out.push(redact_arg_value(&arg)); } serde_json::to_string(&out).ok() } -/// `text` with the password of any `scheme://user:pass@host` in it -/// replaced by [`REDACTED`]. -fn redact_url_password(text: &str) -> String { +/// One argument, or a flag's inline value, as it goes into an export. An +/// environment assignment (`GITHUB_TOKEN=x`, as `docker run -e` takes it) or +/// a header (`Authorization: Bearer x`, as `--header` takes it) whose name +/// says it holds a credential keeps only its name; a URL in it loses its +/// credentials. +fn redact_arg_value(arg: &str) -> String { + let is_name = |s: &str| { + !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + }; + for sep in ['=', ':'] { + if let Some((name, value)) = arg.split_once(sep) { + if is_name(name) && is_secret_name(name) { + let keep = arg.len() - value.trim_start().len(); + return format!("{}{REDACTED}", &arg[..keep]); + } + } + } + redact_url_credentials(arg) +} + +/// `text` with the credentials of any URL in it replaced by [`REDACTED`]: +/// the password of `scheme://user:pass@host`, a bare userinfo whole (it's +/// as likely a token, as in `https://ghp_x@github.com`, as a user name), and +/// the value of a query parameter whose name says it holds a credential +/// (`?api_key=x`). +fn redact_url_credentials(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(at) = rest.find("://") { @@ -3353,21 +3874,99 @@ fn redact_url_password(text: &str) -> String { let authority_end = tail.find(['/', '?', '#']).unwrap_or(tail.len()); match tail[..authority_end].rfind('@') { Some(at_sign) => { - let userinfo = &tail[..at_sign]; - match userinfo.split_once(':') { - Some((user, _)) => { - out.push_str(user); - out.push(':'); - out.push_str(REDACTED); - } - None => out.push_str(userinfo), + if let Some((user, _)) = tail[..at_sign].split_once(':') { + out.push_str(user); + out.push(':'); } + out.push_str(REDACTED); rest = &tail[at_sign..]; } None => rest = tail, } } out.push_str(rest); + + let text = out; + let mut out = String::with_capacity(text.len()); + let mut rest = text.as_str(); + while let Some(i) = rest.find(['?', '&']) { + let (head, tail) = rest.split_at(i + 1); + out.push_str(head); + let end = tail + .find(|c: char| c == '&' || c == '#' || c.is_whitespace()) + .unwrap_or(tail.len()); + rest = match tail[..end].split_once('=') { + Some((name, value)) if !value.is_empty() && is_secret_name(name) => { + out.push_str(name); + out.push('='); + out.push_str(REDACTED); + &tail[end..] + } + _ => tail, + }; + } + out.push_str(rest); + out +} + +/// An HTTP server's `url` as it goes into an export: the whole userinfo +/// (`user:pass@`, or a bare token as in `https://ghp_x@github.com`) and the +/// value of every query parameter are replaced with [`REDACTED`]. Scheme, +/// host, path and parameter names stay, so the row still names its endpoint +/// and passes `validate_mcp_input` on the way back in. +fn redact_http_url(url: &str) -> String { + let mut out = String::with_capacity(url.len()); + let mut rest = url; + if let Some(at) = rest.find("://") { + let (head, tail) = rest.split_at(at + 3); + out.push_str(head); + let authority_end = tail.find(['/', '?', '#']).unwrap_or(tail.len()); + rest = match tail[..authority_end].rfind('@') { + Some(at_sign) => { + out.push_str(REDACTED); + &tail[at_sign..] + } + None => tail, + }; + } + let (rest, fragment) = rest.split_at(rest.find('#').unwrap_or(rest.len())); + match rest.split_once('?') { + Some((path, query)) => { + out.push_str(path); + out.push('?'); + let params: Vec<String> = query + .split('&') + .map(|p| match p.split_once('=') { + Some((name, value)) if !value.is_empty() => format!("{name}={REDACTED}"), + _ => p.to_string(), + }) + .collect(); + out.push_str(¶ms.join("&")); + } + None => out.push_str(rest), + } + out.push_str(fragment); + out +} + +/// `prompt` without the summary block the Compact button parks in a chat's +/// system prompt — the twin of `stripSummaryBlock` in +/// `src/lib/contextUsage.ts`, whose markers these must match. +fn strip_summary_block(prompt: &str) -> String { + const START: &str = "[Loach: earlier conversation summary]"; + const END: &str = "[End of Loach summary]"; + let mut out = String::with_capacity(prompt.len()); + let mut rest = prompt; + while let Some(start) = rest.find(START) { + let Some(end) = rest[start..].find(END) else { break }; + out.push_str(&rest[..start]); + rest = &rest[start + end + END.len()..]; + // The block is followed by a blank line before the user's own text. + for _ in 0..2 { + rest = rest.strip_prefix('\n').unwrap_or(rest); + } + } + out.push_str(rest); out } @@ -3580,6 +4179,128 @@ mod tests { assert_eq!(redact_args_json("not json"), None); } + /// The other places a stdio command line carries a credential — a token + /// as the whole URL userinfo, an env assignment for `docker run -e`, an + /// auth header, a key in the query — lose it too, while flags that only + /// contain "auth" in a longer word keep their values. + #[test] + fn exported_arguments_lose_tokens_in_env_headers_and_queries() { + let args = serde_json::to_string(&[ + "https://ghp_abc@github.com/org/repo", + "-e", + "GITHUB_TOKEN=ghp_abc", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "--env=OPENAI_API_KEY=sk-123", + "--header", + "Authorization: Bearer abc", + "--header=X-Api-Key: abc", + "-H", + "X-Request-Id: 42", + "https://mcp.example.com/sse?api_key=sk-123®ion=eu", + "--author", + "Jane", + "--authority=ca.example.com", + "--auth", + "hunter2", + ]) + .unwrap(); + let out: Vec<String> = + serde_json::from_str(&redact_args_json(&args).expect("a list of strings")).unwrap(); + assert_eq!( + out, + [ + "https://REDACTED@github.com/org/repo", + "-e", + "GITHUB_TOKEN=REDACTED", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "--env=OPENAI_API_KEY=REDACTED", + "--header", + "Authorization: REDACTED", + "--header=X-Api-Key: REDACTED", + "-H", + "X-Request-Id: 42", + "https://mcp.example.com/sse?api_key=REDACTED®ion=eu", + "--author", + "Jane", + "--authority=ca.example.com", + "--auth", + "REDACTED", + ] + ); + } + + /// An HTTP server's URL loses its userinfo (a bare token included) and + /// its query values in an export, but keeps everything that says which + /// endpoint it is — and still parses, so the row imports again. + #[test] + fn exported_http_urls_lose_their_credentials() { + for (url, want) in [ + ("https://ghp_abc@github.com/mcp", "https://REDACTED@github.com/mcp"), + ("https://me:hunter2@host.local:8443/mcp", "https://REDACTED@host.local:8443/mcp"), + ( + "https://mcp.example.com/sse?api_key=sk-123®ion=eu&flag", + "https://mcp.example.com/sse?api_key=REDACTED®ion=REDACTED&flag", + ), + ("http://localhost:3000/mcp", "http://localhost:3000/mcp"), + ("https://example.com/a@b?k=", "https://example.com/a@b?k="), + ("", ""), + ] { + let out = redact_http_url(url); + assert_eq!(out, want, "{url}"); + if !url.is_empty() { + reqwest::Url::parse(&out).expect("a redacted URL still parses"); + } + } + + let (db, _dir) = fresh_db(); + let mut row = mcp_draft("gh", "http"); + row.url = "https://ghp_abc@api.example.com/mcp?token=t0k".into(); + db.upsert_mcp_server(&row).expect("insert"); + let snap = db.snapshot().expect("snapshot"); + assert_eq!( + snap.data.mcp_servers[0].url, + "https://REDACTED@api.example.com/mcp?token=REDACTED" + ); + } + + /// Forking above the end of a compacted range: the summary covers turns + /// the fork doesn't get, so the fork drops it and un-hides the turns it + /// did copy. Forking below the range keeps both. + #[test] + fn fork_before_the_compacted_range_drops_the_summary() { + let (db, _dir) = fresh_db(); + let s = db.create_session("c", "ollama", "llama3", None, None).unwrap(); + let ids: Vec<String> = (0..6) + .map(|i| { + let role = if i % 2 == 0 { "user" } else { "assistant" }; + db.append_message(&s.id, role, &format!("m{i}"), None).unwrap().id + }) + .collect(); + db.mark_messages_compacted(&s.id, &ids[..4]).unwrap(); + db.update_session_system_prompt( + &s.id, + "[Loach: earlier conversation summary]\n- m0..m3\n[End of Loach summary]\n\nBe terse.", + ) + .unwrap(); + + let early = db.fork_session(&s.id, Some(&ids[1])).unwrap(); + assert_eq!(early.system_prompt.as_deref(), Some("Be terse.")); + let copied = db.list_messages(&early.id).unwrap(); + assert_eq!(copied.len(), 2); + assert!(copied.iter().all(|m| m.compacted_at.is_none()), "copies stayed hidden"); + assert_eq!( + db.get_session(&early.id).unwrap().unwrap().system_prompt.as_deref(), + Some("Be terse.") + ); + + let late = db.fork_session(&s.id, Some(&ids[4])).unwrap(); + assert!(late.system_prompt.unwrap().starts_with("[Loach: earlier conversation summary]")); + let copied = db.list_messages(&late.id).unwrap(); + assert_eq!(copied.iter().filter(|m| m.compacted_at.is_some()).count(), 4); + } + /// A workspace root is local to this machine, so it must not travel in /// a snapshot — neither out (someone else's model learning your paths) /// nor back in (a hand-edited export granting a directory nobody picked). @@ -3614,6 +4335,149 @@ mod tests { ); } + /// Exports carry the v2 tag (so a 1.5 build, which reads only v1, + /// refuses them instead of restoring them without the Library); this + /// build still reads v1. + #[test] + fn snapshots_are_tagged_v2_and_v1_still_restores() { + let (db, _dir) = fresh_db(); + assert_eq!(db.snapshot().expect("snapshot").schema, "loach/v2"); + assert!(is_supported_snapshot_schema("loach/v1")); + assert!(is_supported_snapshot_schema("loach/v2")); + assert!(!is_supported_snapshot_schema("loach/v3")); + assert!(!is_supported_snapshot_schema("")); + } + + /// A snapshot that names a Space, folder or fork source it doesn't carry + /// restores with those pointers cleared, and rows whose required parent + /// is missing are dropped — FK enforcement is off for the restore, so + /// nothing else would settle them. + #[test] + fn restore_settles_references_the_snapshot_does_not_carry() { + let (db, _dir) = fresh_db(); + let sp = db.create_space("Work", "", "").unwrap(); + let f = db.create_folder("Research").unwrap(); + let src = db + .create_session("src", "ollama", "llama3", None, Some(&sp.id)) + .unwrap(); + db.set_session_folder(&src.id, Some(&f.id)).unwrap(); + db.append_message(&src.id, "user", "hi", None).unwrap(); + let fork = db.fork_session(&src.id, None).unwrap(); + db.add_space_file(&sp.id, "f.txt", "text/plain", "text", "x", 1, 0) + .unwrap(); + + let mut snap = db.snapshot().unwrap(); + // Trim the parents out, as a hand-edited export might. + snap.data.spaces.clear(); + snap.data.folders.clear(); + snap.data.sessions.retain(|s| s.id != src.id); + + let (db2, _dir2) = fresh_db(); + db2.restore_snapshot(&snap).expect("restore"); + let kept = db2.get_session(&fork.id).unwrap().expect("the fork is kept"); + assert_eq!(kept.space_id, None); + assert_eq!(kept.folder_id, None); + assert_eq!(kept.forked_from_session_id, None); + assert_eq!(db2.list_messages(&fork.id).unwrap().len(), 1); + assert!(db2.list_messages(&src.id).unwrap().is_empty(), "orphan messages dropped"); + assert!(db2.all_space_files().unwrap().is_empty(), "orphan files dropped"); + let violations: i64 = db2 + .conn + .lock() + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r.get(0)) + .unwrap(); + assert_eq!(violations, 0); + } + + /// The setters refuse an unknown `retrieval_mode`, but a snapshot never + /// passes them; restore falls back instead of storing a value the Space + /// page can't label. + #[test] + fn restore_replaces_unknown_retrieval_modes() { + let (db, _dir) = fresh_db(); + let sp = db.create_space("Work", "", "").unwrap(); + let s = db + .create_session("t", "ollama", "llama3", None, Some(&sp.id)) + .unwrap(); + let mut snap = db.snapshot().unwrap(); + snap.data.spaces[0].retrieval_mode = "bogus".into(); + snap.data.sessions[0].retrieval_mode = Some("bogus".into()); + + let (db2, _dir2) = fresh_db(); + db2.restore_snapshot(&snap).expect("restore"); + assert_eq!(db2.get_space(&sp.id).unwrap().unwrap().retrieval_mode, "auto"); + assert_eq!(db2.get_session(&s.id).unwrap().unwrap().retrieval_mode, None); + + // Known values pass through untouched. + snap.data.spaces[0].retrieval_mode = "retrieval".into(); + snap.data.sessions[0].retrieval_mode = Some("full".into()); + db2.restore_snapshot(&snap).expect("restore again"); + assert_eq!(db2.get_space(&sp.id).unwrap().unwrap().retrieval_mode, "retrieval"); + assert_eq!( + db2.get_session(&s.id).unwrap().unwrap().retrieval_mode.as_deref(), + Some("full") + ); + } + + #[test] + fn image_scaling_accepts_only_what_the_ui_offers() { + let ok = r#"{"enabled":true,"mode":"px","px":1024,"percent":50}"#; + assert_eq!(normalize_image_scaling(ok).as_deref(), Some(ok)); + // Re-encoded, so key order and whitespace don't matter. + assert_eq!( + normalize_image_scaling(r#"{ "percent": 25, "px": 64, "mode": "percent", "enabled": false }"#) + .as_deref(), + Some(r#"{"enabled":false,"mode":"percent","px":64,"percent":25}"#) + ); + for bad in [ + "", + "null", + r#"{"enabled":true,"mode":"px","px":1024}"#, + r#"{"enabled":true,"mode":"zoom","px":1024,"percent":50}"#, + r#"{"enabled":true,"mode":"px","px":32,"percent":50}"#, + r#"{"enabled":true,"mode":"px","px":9000,"percent":50}"#, + r#"{"enabled":true,"mode":"percent","px":1024,"percent":0}"#, + r#"{"enabled":true,"mode":"percent","px":1024,"percent":150}"#, + r#"{"enabled":true,"mode":"px","px":1024,"percent":50,"extra":1}"#, + ] { + assert_eq!(normalize_image_scaling(bad), None, "{bad}"); + } + } + + #[test] + fn image_scaling_round_trips_and_restores_screened() { + let (db, _dir) = fresh_db(); + let sp = db.create_space("Work", "", "").unwrap(); + let s = db + .create_session("t", "ollama", "llama3", None, Some(&sp.id)) + .unwrap(); + let json = r#"{"enabled":true,"mode":"percent","px":1024,"percent":40}"#; + db.set_space_image_scaling(&sp.id, Some(json)).unwrap(); + db.set_session_image_scaling(&s.id, Some(json)).unwrap(); + assert_eq!(db.get_space(&sp.id).unwrap().unwrap().image_scaling_json.as_deref(), Some(json)); + assert_eq!(db.get_session(&s.id).unwrap().unwrap().image_scaling_json.as_deref(), Some(json)); + // A fork keeps the chat's own setting. + let fork = db.fork_session(&s.id, None).unwrap(); + assert_eq!(fork.image_scaling_json.as_deref(), Some(json)); + + let mut snap = db.snapshot().unwrap(); + let (db2, _dir2) = fresh_db(); + db2.restore_snapshot(&snap).expect("restore"); + assert_eq!(db2.get_space(&sp.id).unwrap().unwrap().image_scaling_json.as_deref(), Some(json)); + + // Anything the setters wouldn't take goes back to inheriting. + snap.data.spaces[0].image_scaling_json = Some("bogus".into()); + for s in &mut snap.data.sessions { + s.image_scaling_json = Some(r#"{"enabled":true,"mode":"px","px":1,"percent":50}"#.into()); + } + db2.restore_snapshot(&snap).expect("restore again"); + assert_eq!(db2.get_space(&sp.id).unwrap().unwrap().image_scaling_json, None); + assert_eq!(db2.get_session(&s.id).unwrap().unwrap().image_scaling_json, None); + + db.set_session_image_scaling(&s.id, None).unwrap(); + assert_eq!(db.get_session(&s.id).unwrap().unwrap().image_scaling_json, None); + } + #[test] fn workspace_root_clears_back_to_none() { let (db, _dir) = fresh_db(); @@ -3658,16 +4522,20 @@ mod tests { // 5 chars, 10 octets in UTF-8 — the gap a character count would miss. db.append_message(&s.id, "user", "źdźbło", Some("[\"att\"]")) .expect("message"); + // Reasoning, tool calls and metrics are part of the transcript too. + let reply = db.append_message(&s.id, "assistant", "ok", None).expect("reply"); + db.update_message(&reply.id, &s.id, "ok", Some("hmm"), Some("{}"), Some("[]"), None) + .expect("finish reply"); let sp = db.create_space("s", "", "").expect("space"); db.add_space_file(&sp.id, "f.txt", "text/plain", "text", "0123456789", 10, 0) .expect("file"); let st = db.storage_stats().expect("stats"); assert_eq!(st.sessions, 1); - assert_eq!(st.messages, 1); + assert_eq!(st.messages, 2); assert_eq!(st.spaces, 1); assert_eq!(st.space_files, 1); - assert_eq!(st.message_bytes, "źdźbło".len() as i64); + assert_eq!(st.message_bytes, ("źdźbło".len() + "ok".len() + "hmm{}[]".len()) as i64); assert_eq!(st.attachment_bytes, 7); assert_eq!(st.space_bytes, 10); // Session title + the two NULL-coalesced columns. @@ -3803,6 +4671,53 @@ mod tests { db.migrate().expect("third migrate"); } + /// Spaces from before 1.6 keep inlining every file, as they always did; + /// only Spaces created afterwards start on "auto", which may switch to + /// excerpts. Imports of pre-1.6 exports get the same treatment. + #[test] + fn pre_library_spaces_keep_full_text() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("loach.db"); + + { + let db = Database::open(&path).expect("open"); + db.migrate().expect("initial migrate"); + // Rebuild `spaces` as 1.5 left it (no `retrieval_mode`) with one + // Space in it. (SQLite predating DROP COLUMN is why this goes the + // long way round.) + let conn = db.conn.lock(); + conn.execute_batch( + r#" + DROP TABLE spaces; + CREATE TABLE spaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + instructions TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + INSERT INTO spaces (id, name, created_at, updated_at) VALUES ('old', 'Notes', 0, 0); + "#, + ) + .expect("simulate a 1.5 database"); + } + + let db = Database::open(&path).expect("reopen"); + db.migrate().expect("migrate a 1.5 database"); + let old = db.get_space("old").expect("read").expect("kept"); + assert_eq!(old.retrieval_mode, "full"); + let new = db.create_space("Fresh", "", "").expect("create"); + assert_eq!(new.retrieval_mode, "auto"); + + let imported: Space = serde_json::from_value(serde_json::json!({ + "id": "s", "name": "Notes", "description": "", "instructions": "", + "created_at": 0, "updated_at": 0, + })) + .expect("pre-1.6 export row"); + assert_eq!(imported.retrieval_mode, "full"); + } + /// A migration interrupted part-way through must be finishable on the /// next launch, not fatal forever. /// @@ -3898,6 +4813,34 @@ mod tests { assert_eq!(db.get_session(&s.id).unwrap().unwrap().label, None); } + /// Pinning lifts a chat to the top of the list; unpinning must not, or + /// a month-old chat would land in "Today" just for being unpinned. + #[test] + fn unpinning_leaves_updated_at_alone() { + let (db, _dir) = fresh_db(); + let s = db + .create_session("t", "ollama", "llama3", None, None) + .expect("create"); + db.conn + .lock() + .execute("UPDATE sessions SET updated_at = 1 WHERE id = ?1", params![s.id]) + .unwrap(); + + db.pin_session(&s.id, true).expect("pin"); + let pinned = db.get_session(&s.id).unwrap().unwrap(); + assert!(pinned.pinned_at.is_some()); + assert!(pinned.updated_at > 1, "pinning surfaces the chat"); + + db.conn + .lock() + .execute("UPDATE sessions SET updated_at = 1 WHERE id = ?1", params![s.id]) + .unwrap(); + db.pin_session(&s.id, false).expect("unpin"); + let unpinned = db.get_session(&s.id).unwrap().unwrap(); + assert_eq!(unpinned.pinned_at, None); + assert_eq!(unpinned.updated_at, 1, "unpinning must not re-sort the chat"); + } + #[test] fn deleting_folder_releases_its_chats_instead_of_deleting_them() { let (db, _dir) = fresh_db(); @@ -3925,6 +4868,33 @@ mod tests { assert_eq!(after[0].folder_id, None); } + #[test] + fn moving_a_chat_between_spaces_keeps_its_place_in_the_list() { + let (db, _dir) = fresh_db(); + let s = db + .create_session("t", "ollama", "llama3", None, None) + .expect("create"); + let sp = db.create_space("Work", "", "").expect("space"); + + db.set_session_space(&s.id, Some(&sp.id)).expect("move in"); + let listed = db.list_sessions().unwrap(); + assert_eq!(listed[0].space_id.as_deref(), Some(sp.id.as_str())); + assert_eq!( + listed[0].updated_at, s.updated_at, + "moving a chat must not re-sort the list the way pinning does", + ); + + // A Space that doesn't exist is refused, not stored as a dangling id. + assert!(db.set_session_space(&s.id, Some("no-such-space")).is_err()); + assert_eq!( + db.get_session(&s.id).unwrap().unwrap().space_id.as_deref(), + Some(sp.id.as_str()), + ); + + db.set_session_space(&s.id, None).expect("move out"); + assert_eq!(db.get_session(&s.id).unwrap().unwrap().space_id, None); + } + #[test] fn deleting_session_cascades_to_messages() { let (db, _dir) = fresh_db(); diff --git a/src-tauri/src/db/library.rs b/src-tauri/src/db/library.rs new file mode 100644 index 0000000..f159161 --- /dev/null +++ b/src-tauri/src/db/library.rs @@ -0,0 +1,1673 @@ +//! Library storage: items (texts, files, links), the datasets that group +//! them, the links that attach either to a Space or a chat, and the +//! retrieval index (`rag_chunks`) built over every text source — Library +//! items and a Space's own uploads alike. Lives beside `db.rs` as a child +//! module so it shares `Database`'s private connection handles without +//! growing that file further. +//! +//! Items are stored once and *referenced* everywhere: editing a text here +//! changes what every Space and chat linked to it sees on its next turn. + +use std::collections::HashMap; + +use anyhow::{anyhow, Result}; +use chrono::Utc; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use super::{Database, SnapshotData}; + +/// `rag_chunks.doc_kind` for a Library item. +pub const DOC_LIBRARY: &str = "library"; +/// `rag_chunks.doc_kind` for one of a Space's own uploads. +pub const DOC_SPACE_FILE: &str = "space_file"; + +/// A Library item with its full body — what `library_get_items` returns and +/// what a snapshot carries. `content` is extracted text for `media: "text"` +/// and base64 for `media: "image"`. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LibraryItem { + pub id: String, + /// `"text"` (written in the Library), `"file"` (uploaded) or `"link"`. + pub kind: String, + pub title: String, + #[serde(default)] + pub mime: String, + /// `"text"` or `"image"`. + #[serde(default = "default_media")] + pub media: String, + pub content: String, + /// Size of the original: file bytes, page bytes, or the text's UTF-8 + /// length. + #[serde(default)] + pub size: i64, + #[serde(default)] + pub url: Option<String>, + #[serde(default)] + pub fetched_at: Option<i64>, + #[serde(default)] + pub content_hash: String, + /// Extraction or fetching hit a cap, so `content` is incomplete. + #[serde(default)] + pub truncated: bool, + pub created_at: i64, + pub updated_at: i64, +} + +fn default_media() -> String { + "text".to_string() +} + +/// A Library item without its body, for lists. `chars` sizes the body (0 for +/// images), `preview` is its opening for text cards, and `chunks` / +/// `embedded` report how far the retrieval index has got with it. +#[derive(Debug, Serialize, Clone)] +pub struct LibraryItemMeta { + pub id: String, + pub kind: String, + pub title: String, + pub mime: String, + pub media: String, + pub size: i64, + pub chars: i64, + pub preview: String, + pub url: Option<String>, + pub fetched_at: Option<i64>, + pub content_hash: String, + pub truncated: bool, + pub chunks: i64, + pub embedded: i64, + pub created_at: i64, + pub updated_at: i64, +} + +/// A named, ordered group of items. An item can sit in several datasets. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LibraryDataset { + pub id: String, + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub item_ids: Vec<String>, + pub created_at: i64, + pub updated_at: i64, +} + +/// One Library item or dataset linked into a Space or chat (`owner_id`). +/// Exactly one of `item_id` / `dataset_id` is set. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LibrarySource { + pub id: String, + pub owner_id: String, + #[serde(default)] + pub item_id: Option<String>, + #[serde(default)] + pub dataset_id: Option<String>, + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default)] + pub position: i32, + pub created_at: i64, +} + +fn default_enabled() -> bool { + true +} + +/// What links to a set of items / datasets, for the delete confirmation. +#[derive(Debug, Serialize, Clone)] +pub struct LibraryUsage { + pub spaces: Vec<String>, + pub chats: i64, +} + +/// Which kind of thing a [`LibrarySource`] is attached to. Maps to one of +/// two tables with identical shape, so the FK can cascade from its owner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceOwner { + Space, + Session, +} + +impl SourceOwner { + pub fn parse(s: &str) -> Result<Self> { + match s { + "space" => Ok(Self::Space), + "session" => Ok(Self::Session), + other => Err(anyhow!("unknown source owner `{other}`")), + } + } + + fn table(self) -> &'static str { + match self { + Self::Space => "space_library_sources", + Self::Session => "session_library_sources", + } + } +} + +/// Fields for a new Library item. +pub struct NewLibraryItem<'a> { + pub kind: &'a str, + pub title: &'a str, + pub mime: &'a str, + pub media: &'a str, + pub content: &'a str, + pub size: i64, + pub url: Option<&'a str>, + pub fetched_at: Option<i64>, + pub truncated: bool, +} + +/// A chunk waiting for an embedding, with the title of the document it came +/// from (embedding the title alongside lifts recall for "what does X say" +/// questions). +pub struct PendingChunk { + pub id: i64, + pub text: String, + pub title: String, +} + +/// A chunk id with its unit-length embedding. +pub type ChunkVector = (i64, Vec<f32>); + +/// One row of the retrieval index. +#[derive(Debug, Clone)] +pub struct ChunkRow { + pub id: i64, + pub doc_kind: String, + pub doc_id: String, + pub seq: i64, + pub text: String, +} + +/// Lowercase hex SHA-256 of an item body. The frontend computes the same +/// digest to spot a duplicate before uploading it. +pub fn content_hash(content: &str) -> String { + Sha256::digest(content.as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +/// Rebuild the retrieval chunks for one document. Clearing first makes this +/// the single path for create, edit and refresh; the triggers keep the FTS +/// index in step. Embeddings are left NULL for the indexer to fill. +pub(super) fn replace_chunks(conn: &Connection, doc_kind: &str, doc_id: &str, text: &str) -> Result<()> { + conn.execute( + "DELETE FROM rag_chunks WHERE doc_kind = ?1 AND doc_id = ?2", + params![doc_kind, doc_id], + )?; + let mut stmt = conn.prepare( + "INSERT INTO rag_chunks (doc_kind, doc_id, seq, text) VALUES (?1, ?2, ?3, ?4)", + )?; + for (seq, chunk) in crate::rag::chunk_text(text).iter().enumerate() { + stmt.execute(params![doc_kind, doc_id, seq as i64, chunk])?; + } + Ok(()) +} + +/// The newest chunk id: chunks written after it's read get higher ones. +fn last_chunk_id(conn: &Connection) -> Result<i64> { + Ok(conn.query_row("SELECT COALESCE(MAX(id), 0) FROM rag_chunks", [], |r| r.get(0))?) +} + +/// Move chunks with ids up to `last` — one document's, or with `None` every +/// one — to fresh ids with no embedding, as if just written. Resetting in +/// place isn't enough: an indexer batch already in flight for the old ids +/// would store its stale vectors (the old title, the old model) right over +/// the reset. Ids are `AUTOINCREMENT`, never reused, so those writes now +/// land nowhere — and the copies get ids above `last`, out of the delete's +/// reach. +pub(super) fn reissue_chunks(conn: &Connection, doc: Option<(&str, &str)>, last: i64) -> Result<()> { + let (kind, id) = doc.unzip(); + let filter = "id <= ?3 AND (?1 IS NULL OR (doc_kind = ?1 AND doc_id = ?2))"; + conn.execute( + &format!( + "INSERT INTO rag_chunks (doc_kind, doc_id, seq, text) + SELECT doc_kind, doc_id, seq, text FROM rag_chunks WHERE {filter} ORDER BY id" + ), + params![kind, id, last], + )?; + conn.execute(&format!("DELETE FROM rag_chunks WHERE {filter}"), params![kind, id, last])?; + Ok(()) +} + +/// Chunks [`Database::clear_rag_embeddings`] moves per transaction. +const REISSUE_BATCH: i64 = 500; + +/// SQL keeping the chunks whose document is in the scope bound to `param` +/// (a JSON array of `kind:id` keys; a kind has no colon). Compared as the +/// `(doc_kind, doc_id)` pair, each document is looked up through +/// `idx_rag_chunks_doc` — comparing `doc_kind || ':' || doc_id` read every +/// chunk in the index, embedding and all, on every search. +fn in_scope(param: &str) -> String { + format!( + "(doc_kind, doc_id) IN (SELECT substr(value, 1, instr(value, ':') - 1), + substr(value, instr(value, ':') + 1) + FROM json_each({param}))" + ) +} + +const ITEM_META_SELECT: &str = " + SELECT i.id, i.kind, i.title, i.mime, i.media, i.size, + CASE WHEN i.media = 'text' THEN LENGTH(i.content) ELSE 0 END, + CASE WHEN i.media = 'text' THEN substr(i.content, 1, 280) ELSE '' END, + i.url, i.fetched_at, i.content_hash, i.truncated, + (SELECT COUNT(*) FROM rag_chunks c + WHERE c.doc_kind = 'library' AND c.doc_id = i.id), + (SELECT COUNT(*) FROM rag_chunks c + WHERE c.doc_kind = 'library' AND c.doc_id = i.id AND c.embed_model = ?1), + i.created_at, i.updated_at + FROM library_items i"; + +fn item_meta_from_row(r: &rusqlite::Row) -> rusqlite::Result<LibraryItemMeta> { + Ok(LibraryItemMeta { + id: r.get(0)?, + kind: r.get(1)?, + title: r.get(2)?, + mime: r.get(3)?, + media: r.get(4)?, + size: r.get(5)?, + chars: r.get(6)?, + preview: r.get(7)?, + url: r.get(8)?, + fetched_at: r.get(9)?, + content_hash: r.get(10)?, + truncated: r.get::<_, i64>(11)? != 0, + chunks: r.get(12)?, + embedded: r.get(13)?, + created_at: r.get(14)?, + updated_at: r.get(15)?, + }) +} + +fn item_from_row(r: &rusqlite::Row) -> rusqlite::Result<LibraryItem> { + Ok(LibraryItem { + id: r.get(0)?, + kind: r.get(1)?, + title: r.get(2)?, + mime: r.get(3)?, + media: r.get(4)?, + content: r.get(5)?, + size: r.get(6)?, + url: r.get(7)?, + fetched_at: r.get(8)?, + content_hash: r.get(9)?, + truncated: r.get::<_, i64>(10)? != 0, + created_at: r.get(11)?, + updated_at: r.get(12)?, + }) +} + +const ITEM_SELECT: &str = "SELECT id, kind, title, mime, media, content, size, url, fetched_at, + content_hash, truncated, created_at, updated_at + FROM library_items"; + +fn source_from_row(r: &rusqlite::Row) -> rusqlite::Result<LibrarySource> { + Ok(LibrarySource { + id: r.get(0)?, + owner_id: r.get(1)?, + item_id: r.get(2)?, + dataset_id: r.get(3)?, + enabled: r.get::<_, i64>(4)? != 0, + position: r.get(5)?, + created_at: r.get(6)?, + }) +} + +/// Replay a snapshot's Library rows inside the restore transaction. +pub(super) fn restore_library(conn: &Connection, d: &SnapshotData) -> Result<()> { + { + let mut stmt = conn.prepare( + "INSERT INTO library_items (id, kind, title, mime, media, content, size, url, + fetched_at, content_hash, truncated, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + )?; + for i in &d.library_items { + // A hand-edited or old export may lack the digest; recompute so + // duplicate detection keeps working on restored rows. + let hash = if i.content_hash.is_empty() { + content_hash(&i.content) + } else { + i.content_hash.clone() + }; + stmt.execute(params![ + i.id, + i.kind, + i.title, + i.mime, + i.media, + i.content, + i.size, + i.url, + i.fetched_at, + hash, + i.truncated as i64, + i.created_at, + i.updated_at, + ])?; + } + } + for ds in &d.library_datasets { + conn.execute( + "INSERT INTO library_datasets (id, name, description, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ds.id, ds.name, ds.description, ds.created_at, ds.updated_at], + )?; + for (pos, item_id) in ds.item_ids.iter().enumerate() { + conn.execute( + "INSERT OR IGNORE INTO library_dataset_items (dataset_id, item_id, position) + VALUES (?1, ?2, ?3)", + params![ds.id, item_id, pos as i64], + )?; + } + } + for (owner, rows) in [ + (SourceOwner::Space, &d.space_library_sources), + (SourceOwner::Session, &d.session_library_sources), + ] { + let sql = format!( + "INSERT OR IGNORE INTO {} (id, owner_id, item_id, dataset_id, enabled, position, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + owner.table() + ); + for s in rows { + conn.execute( + &sql, + params![ + s.id, + s.owner_id, + s.item_id, + s.dataset_id, + s.enabled as i64, + s.position, + s.created_at + ], + )?; + } + } + Ok(()) +} + +impl Database { + // ------------ items ------------ + + /// Every item, newest-edited first, without bodies. `embed_model` is the + /// indexer's current model key, used to count embedded chunks. + pub fn list_library_items(&self, embed_model: Option<&str>) -> Result<Vec<LibraryItemMeta>> { + self.with_read(|conn| { + let mut stmt = + conn.prepare(&format!("{ITEM_META_SELECT} ORDER BY i.updated_at DESC"))?; + let rows = stmt + .query_map(params![embed_model], item_meta_from_row)? + .collect::<Result<Vec<_>, _>>()?; + Ok(rows) + }) + } + + fn library_item_meta(&self, id: &str, embed_model: Option<&str>) -> Result<LibraryItemMeta> { + self.with_read(|conn| { + conn.query_row( + &format!("{ITEM_META_SELECT} WHERE i.id = ?2"), + params![embed_model, id], + item_meta_from_row, + ) + .optional()? + .ok_or_else(|| anyhow!("library item not found")) + }) + } + + /// Full rows for `ids`, in the order asked for; unknown ids are skipped. + pub fn get_library_items(&self, ids: &[String]) -> Result<Vec<LibraryItem>> { + self.with_read(|conn| { + let mut stmt = conn.prepare(&format!("{ITEM_SELECT} WHERE id = ?1"))?; + let mut out = Vec::with_capacity(ids.len()); + for id in ids { + if let Some(item) = stmt.query_row(params![id], item_from_row).optional()? { + out.push(item); + } + } + Ok(out) + }) + } + + /// Every item with its body, for export. + pub fn all_library_items(&self) -> Result<Vec<LibraryItem>> { + self.with_read(|conn| { + let mut stmt = conn.prepare(&format!("{ITEM_SELECT} ORDER BY created_at"))?; + let rows = stmt + .query_map([], item_from_row)? + .collect::<Result<Vec<_>, _>>()?; + Ok(rows) + }) + } + + pub fn create_library_item( + &self, + item: &NewLibraryItem, + embed_model: Option<&str>, + ) -> Result<LibraryItemMeta> { + if !matches!(item.kind, "text" | "file" | "link") { + return Err(anyhow!("unknown library item kind `{}`", item.kind)); + } + if !matches!(item.media, "text" | "image") { + return Err(anyhow!("unknown library media `{}`", item.media)); + } + let id = Uuid::new_v4().to_string(); + let now = Utc::now().timestamp_millis(); + { + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO library_items (id, kind, title, mime, media, content, size, url, + fetched_at, content_hash, truncated, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?12)", + params![ + id, + item.kind, + item.title, + item.mime, + item.media, + item.content, + item.size, + item.url, + item.fetched_at, + content_hash(item.content), + item.truncated as i64, + now, + ], + )?; + if item.media == "text" { + replace_chunks(&tx, DOC_LIBRARY, &id, item.content)?; + } + tx.commit()?; + } + self.library_item_meta(&id, embed_model) + } + + /// Rename an item and return its stored row. The indexer embeds each + /// chunk with its document's title, so a real change also marks the + /// item's chunks for re-embedding. + pub fn rename_library_item( + &self, + id: &str, + title: &str, + embed_model: Option<&str>, + ) -> Result<LibraryItemMeta> { + let now = Utc::now().timestamp_millis(); + { + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + let changed = tx.execute( + "UPDATE library_items SET title = ?1, updated_at = ?2 WHERE id = ?3 AND title IS NOT ?1", + params![title, now, id], + )?; + if changed > 0 { + reissue_chunks(&tx, Some((DOC_LIBRARY, id)), last_chunk_id(&tx)?)?; + } + tx.commit()?; + } + self.library_item_meta(id, embed_model) + } + + /// Replace an item's body (a text edited, a link re-fetched) and rebuild + /// its chunks. `fetched_at` is only written when given. + #[allow(clippy::too_many_arguments)] + pub fn replace_library_content( + &self, + id: &str, + title: Option<&str>, + content: &str, + mime: Option<&str>, + size: i64, + truncated: bool, + fetched_at: Option<i64>, + embed_model: Option<&str>, + ) -> Result<LibraryItemMeta> { + let now = Utc::now().timestamp_millis(); + { + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + let media: Option<String> = tx + .query_row( + "SELECT media FROM library_items WHERE id = ?1", + params![id], + |r| r.get(0), + ) + .optional()?; + let Some(media) = media else { + return Err(anyhow!("library item not found")); + }; + if media != "text" { + return Err(anyhow!("only text items can be edited")); + } + tx.execute( + "UPDATE library_items + SET title = COALESCE(?1, title), content = ?2, mime = COALESCE(?3, mime), + size = ?4, truncated = ?5, content_hash = ?6, + fetched_at = COALESCE(?7, fetched_at), updated_at = ?8 + WHERE id = ?9", + params![ + title, + content, + mime, + size, + truncated as i64, + content_hash(content), + fetched_at, + now, + id + ], + )?; + replace_chunks(&tx, DOC_LIBRARY, id, content)?; + tx.commit()?; + } + self.library_item_meta(id, embed_model) + } + + /// Delete items. Dataset memberships and Space / chat links cascade; the + /// `library_items_rag_ad` trigger drops their chunks. + pub fn delete_library_items(&self, ids: &[String]) -> Result<()> { + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + for id in ids { + tx.execute("DELETE FROM library_items WHERE id = ?1", params![id])?; + } + tx.commit()?; + Ok(()) + } + + // ------------ datasets ------------ + + pub fn list_library_datasets(&self) -> Result<Vec<LibraryDataset>> { + self.with_read(|conn| { + let mut members: HashMap<String, Vec<String>> = HashMap::new(); + let mut stmt = conn.prepare( + "SELECT dataset_id, item_id FROM library_dataset_items + ORDER BY dataset_id, position, item_id", + )?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; + for row in rows { + let (ds, item) = row?; + members.entry(ds).or_default().push(item); + } + let mut stmt = conn.prepare( + "SELECT id, name, description, created_at, updated_at + FROM library_datasets ORDER BY updated_at DESC", + )?; + let rows = stmt + .query_map([], |r| { + Ok(LibraryDataset { + id: r.get(0)?, + name: r.get(1)?, + description: r.get(2)?, + item_ids: Vec::new(), + created_at: r.get(3)?, + updated_at: r.get(4)?, + }) + })? + .collect::<Result<Vec<_>, _>>()?; + Ok(rows + .into_iter() + .map(|mut d| { + d.item_ids = members.remove(&d.id).unwrap_or_default(); + d + }) + .collect()) + }) + } + + pub fn create_library_dataset( + &self, + name: &str, + description: &str, + item_ids: &[String], + ) -> Result<LibraryDataset> { + let id = Uuid::new_v4().to_string(); + let now = Utc::now().timestamp_millis(); + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO library_datasets (id, name, description, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?4)", + params![id, name, description, now], + )?; + let item_ids = insert_members(&tx, &id, item_ids)?; + tx.commit()?; + Ok(LibraryDataset { + id, + name: name.to_string(), + description: description.to_string(), + item_ids, + created_at: now, + updated_at: now, + }) + } + + pub fn update_library_dataset(&self, id: &str, name: &str, description: &str) -> Result<()> { + let now = Utc::now().timestamp_millis(); + let conn = self.conn.lock(); + conn.execute( + "UPDATE library_datasets SET name = ?1, description = ?2, updated_at = ?3 WHERE id = ?4", + params![name, description, now, id], + )?; + Ok(()) + } + + /// Replace a dataset's members with `item_ids`, in that order. Covers + /// add, remove and reorder with one call. Returns the stored list (ids + /// that don't exist are dropped). + pub fn set_library_dataset_items(&self, id: &str, item_ids: &[String]) -> Result<Vec<String>> { + let now = Utc::now().timestamp_millis(); + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + let changed = tx.execute( + "UPDATE library_datasets SET updated_at = ?1 WHERE id = ?2", + params![now, id], + )?; + if changed == 0 { + return Err(anyhow!("dataset not found")); + } + tx.execute( + "DELETE FROM library_dataset_items WHERE dataset_id = ?1", + params![id], + )?; + let stored = insert_members(&tx, id, item_ids)?; + tx.commit()?; + Ok(stored) + } + + /// Delete datasets. Their items stay in the Library; links to the + /// datasets themselves cascade away. + pub fn delete_library_dataset(&self, id: &str) -> Result<()> { + let conn = self.conn.lock(); + conn.execute("DELETE FROM library_datasets WHERE id = ?1", params![id])?; + Ok(()) + } + + // ------------ links into Spaces and chats ------------ + + pub fn list_library_sources(&self, owner: SourceOwner, owner_id: &str) -> Result<Vec<LibrarySource>> { + self.with_read(|conn| { + let mut stmt = conn.prepare(&format!( + "SELECT id, owner_id, item_id, dataset_id, enabled, position, created_at + FROM {} WHERE owner_id = ?1 ORDER BY position, created_at", + owner.table() + ))?; + let rows = stmt + .query_map(params![owner_id], source_from_row)? + .collect::<Result<Vec<_>, _>>()?; + Ok(rows) + }) + } + + pub fn all_library_sources(&self, owner: SourceOwner) -> Result<Vec<LibrarySource>> { + self.with_read(|conn| { + let mut stmt = conn.prepare(&format!( + "SELECT id, owner_id, item_id, dataset_id, enabled, position, created_at + FROM {} ORDER BY owner_id, position", + owner.table() + ))?; + let rows = stmt + .query_map([], source_from_row)? + .collect::<Result<Vec<_>, _>>()?; + Ok(rows) + }) + } + + /// Link items and datasets to an owner, appended after what's there. + /// Anything already linked is skipped. Returns the owner's full list. + pub fn add_library_sources( + &self, + owner: SourceOwner, + owner_id: &str, + item_ids: &[String], + dataset_ids: &[String], + ) -> Result<Vec<LibrarySource>> { + let now = Utc::now().timestamp_millis(); + { + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + let mut next: i64 = tx.query_row( + &format!( + "SELECT COALESCE(MAX(position) + 1, 0) FROM {} WHERE owner_id = ?1", + owner.table() + ), + params![owner_id], + |r| r.get(0), + )?; + // Ids that no longer resolve (deleted in another view meanwhile) + // are skipped, as `insert_members` does, rather than failing the + // whole batch on the foreign key. + let sql = format!( + "INSERT OR IGNORE INTO {} (id, owner_id, item_id, dataset_id, enabled, position, created_at) + SELECT ?1, ?2, ?3, ?4, 1, ?5, ?6 + WHERE EXISTS (SELECT 1 FROM library_items WHERE id = ?3) + OR EXISTS (SELECT 1 FROM library_datasets WHERE id = ?4)", + owner.table() + ); + let refs = item_ids + .iter() + .map(|i| (Some(i.as_str()), None)) + .chain(dataset_ids.iter().map(|d| (None, Some(d.as_str())))); + for (item, dataset) in refs { + let added = tx.execute( + &sql, + params![Uuid::new_v4().to_string(), owner_id, item, dataset, next, now], + )?; + next += added as i64; + } + tx.commit()?; + } + self.list_library_sources(owner, owner_id) + } + + pub fn remove_library_source(&self, owner: SourceOwner, id: &str) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + &format!("DELETE FROM {} WHERE id = ?1", owner.table()), + params![id], + )?; + Ok(()) + } + + pub fn set_library_source_enabled(&self, owner: SourceOwner, id: &str, enabled: bool) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + &format!("UPDATE {} SET enabled = ?1 WHERE id = ?2", owner.table()), + params![enabled as i64, id], + )?; + Ok(()) + } + + /// Spaces (by name) and the number of chats that link any of the given + /// items or datasets directly. + pub fn library_usage(&self, item_ids: &[String], dataset_ids: &[String]) -> Result<LibraryUsage> { + let items = serde_json::to_string(item_ids)?; + let datasets = serde_json::to_string(dataset_ids)?; + self.with_read(|conn| { + let mut stmt = conn.prepare( + "SELECT DISTINCT s.name FROM space_library_sources l + JOIN spaces s ON s.id = l.owner_id + WHERE l.item_id IN (SELECT value FROM json_each(?1)) + OR l.dataset_id IN (SELECT value FROM json_each(?2)) + ORDER BY s.name COLLATE NOCASE", + )?; + let spaces = stmt + .query_map(params![items, datasets], |r| r.get::<_, String>(0))? + .collect::<Result<Vec<_>, _>>()?; + let chats: i64 = conn.query_row( + "SELECT COUNT(DISTINCT owner_id) FROM session_library_sources + WHERE item_id IN (SELECT value FROM json_each(?1)) + OR dataset_id IN (SELECT value FROM json_each(?2))", + params![items, datasets], + |r| r.get(0), + )?; + Ok(LibraryUsage { spaces, chats }) + }) + } + + // ------------ retrieval index ------------ + + /// Chunk every text document that has no chunks yet: rows from before + /// the index existed, and everything after a restore (snapshots don't + /// carry the index). Returns how many documents were chunked. + /// + /// This runs on every indexer wake, so finding the work reads only ids, + /// through the read pool — never the document bodies. Those are read and + /// chunked a few documents per transaction, so rebuilding a restored + /// Library doesn't hold the write lock (and every send) for its whole + /// length. + pub fn backfill_rag_chunks(&self) -> Result<usize> { + const BATCH: usize = 16; + let todo: Vec<(&'static str, String)> = self.with_read(|conn| { + let mut out = Vec::new(); + for (kind, sql) in [ + ( + DOC_LIBRARY, + "SELECT i.id FROM library_items i + WHERE i.media = 'text' + AND NOT EXISTS (SELECT 1 FROM rag_chunks c + WHERE c.doc_kind = 'library' AND c.doc_id = i.id)", + ), + ( + DOC_SPACE_FILE, + "SELECT f.id FROM space_files f + WHERE f.kind = 'text' + AND NOT EXISTS (SELECT 1 FROM rag_chunks c + WHERE c.doc_kind = 'space_file' AND c.doc_id = f.id)", + ), + ] { + let mut stmt = conn.prepare(sql)?; + for id in stmt.query_map([], |r| r.get::<_, String>(0))? { + out.push((kind, id?)); + } + } + Ok(out) + })?; + let mut chunked = 0; + for batch in todo.chunks(BATCH) { + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + for (kind, id) in batch { + // Checked again inside the write: a document chunked since the + // ids were read (edited, say) is left as it is. + let sql = if *kind == DOC_LIBRARY { + "SELECT content FROM library_items WHERE id = ?1 + AND NOT EXISTS (SELECT 1 FROM rag_chunks c + WHERE c.doc_kind = 'library' AND c.doc_id = ?1)" + } else { + "SELECT data FROM space_files WHERE id = ?1 + AND NOT EXISTS (SELECT 1 FROM rag_chunks c + WHERE c.doc_kind = 'space_file' AND c.doc_id = ?1)" + }; + let text: Option<String> = tx.query_row(sql, params![id], |r| r.get(0)).optional()?; + if let Some(text) = text.filter(|t| !t.trim().is_empty()) { + replace_chunks(&tx, kind, id, &text)?; + chunked += 1; + } + } + tx.commit()?; + } + Ok(chunked) + } + + /// Up to `limit` chunks not yet embedded with `model`, oldest first. + pub fn rag_pending_embeddings(&self, model: &str, limit: usize) -> Result<Vec<PendingChunk>> { + self.with_read(|conn| { + let mut stmt = conn.prepare( + "SELECT c.id, c.text, COALESCE(li.title, sf.name, '') + FROM rag_chunks c + LEFT JOIN library_items li ON c.doc_kind = 'library' AND li.id = c.doc_id + LEFT JOIN space_files sf ON c.doc_kind = 'space_file' AND sf.id = c.doc_id + WHERE c.embed_model IS NOT ?1 + ORDER BY c.id LIMIT ?2", + )?; + let rows = stmt + .query_map(params![model, limit as i64], |r| { + Ok(PendingChunk { + id: r.get(0)?, + text: r.get(1)?, + title: r.get(2)?, + }) + })? + .collect::<Result<Vec<_>, _>>()?; + Ok(rows) + }) + } + + /// Store unit-length embeddings. A chunk deleted or reissued meanwhile + /// (its document edited, renamed or deleted, or the index rebuilt) simply + /// updates nothing: chunk ids are never reused. + pub fn store_rag_embeddings(&self, model: &str, rows: &[ChunkVector]) -> Result<()> { + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare( + "UPDATE rag_chunks SET embedding = ?1, embed_model = ?2 WHERE id = ?3", + )?; + for (id, v) in rows { + stmt.execute(params![crate::rag::vector_to_blob(v), model, id])?; + } + } + tx.commit()?; + Ok(()) + } + + /// Mark chunks the model refused or failed on, on their own, as done for + /// `model`, without a vector, so they stop holding up the rest of the + /// index. They stay keyword-searchable; an edit, new embedding settings + /// or a rebuild gives them another try. + pub fn skip_rag_chunks(&self, model: &str, ids: &[i64]) -> Result<()> { + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare( + "UPDATE rag_chunks SET embedding = NULL, embed_model = ?1 WHERE id = ?2", + )?; + for id in ids { + stmt.execute(params![model, id])?; + } + } + tx.commit()?; + Ok(()) + } + + /// Put the chunks [`skip_rag_chunks`](Self::skip_rag_chunks) set aside + /// for `model` back in the queue — the embedding settings changed but + /// kept the model (another server), so its vectors stay. + pub fn retry_skipped_rag_chunks(&self, model: &str) -> Result<()> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE rag_chunks SET embed_model = NULL WHERE embed_model = ?1 AND embedding IS NULL", + params![model], + )?; + Ok(()) + } + + /// `(total chunks, chunks done with model)` across the whole index — + /// "done" includes chunks [`skip_rag_chunks`](Self::skip_rag_chunks) set aside. + pub fn rag_counts(&self, model: Option<&str>) -> Result<(i64, i64)> { + self.with_read(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(embed_model = ?1), 0) FROM rag_chunks", + params![model], + |r| Ok((r.get(0)?, r.get(1)?)), + )?) + }) + } + + /// Forget every embedding so the indexer re-embeds from scratch, by + /// reissuing every chunk — [`REISSUE_BATCH`] per transaction, so + /// rebuilding a large index doesn't hold the write lock (and every send) + /// for its whole length, as one copy of the table did. Chunks written in + /// between get ids above the starting point and are fresh already. Each + /// batch moves its chunks whole, so a rebuild cut short leaves every + /// chunk in the index once, the ones it didn't reach still on their old + /// vectors until the next rebuild. + pub fn clear_rag_embeddings(&self) -> Result<()> { + let last = last_chunk_id(&self.conn.lock())?; + loop { + let mut conn = self.conn.lock(); + let tx = conn.transaction()?; + let upto: Option<i64> = tx.query_row( + "SELECT MAX(id) FROM (SELECT id FROM rag_chunks WHERE id <= ?1 ORDER BY id LIMIT ?2)", + params![last, REISSUE_BATCH], + |r| r.get(0), + )?; + let Some(upto) = upto else { + return Ok(()); + }; + reissue_chunks(&tx, None, upto)?; + tx.commit()?; + } + } + + /// BM25-ranked chunk ids inside `scope` (a JSON array of `kind:id` + /// strings), best first. `fts_query` must already be a valid FTS5 + /// expression (see `rag::build_fts_query`). + pub fn rag_keyword_search(&self, scope: &str, fts_query: &str, limit: usize) -> Result<Vec<i64>> { + self.with_read(|conn| { + let mut stmt = conn.prepare( + "SELECT c.id FROM rag_chunks_fts + JOIN rag_chunks c ON c.id = rag_chunks_fts.rowid + WHERE rag_chunks_fts MATCH ?1 + AND (c.doc_kind || ':' || c.doc_id) IN (SELECT value FROM json_each(?2)) + ORDER BY bm25(rag_chunks_fts) LIMIT ?3", + )?; + let rows = stmt + .query_map(params![fts_query, scope, limit as i64], |r| r.get(0))? + .collect::<Result<Vec<_>, _>>()?; + Ok(rows) + }) + } + + /// `(chunk id, embedding)` for every chunk in `scope` embedded with + /// `model`, plus how many chunks in scope exist at all and how many of + /// those the indexer hasn't reached yet. + pub fn rag_vectors(&self, scope: &str, model: &str) -> Result<(Vec<ChunkVector>, i64, i64)> { + self.with_read(|conn| { + let mut stmt = conn.prepare(&format!( + "SELECT id, embedding FROM rag_chunks + WHERE embed_model = ?1 AND embedding IS NOT NULL AND {}", + in_scope("?2") + ))?; + let rows = stmt + .query_map(params![model, scope], |r| { + let blob: Vec<u8> = r.get(1)?; + Ok((r.get::<_, i64>(0)?, crate::rag::blob_to_vector(&blob))) + })? + .collect::<Result<Vec<_>, _>>()?; + let (total, pending): (i64, i64) = conn.query_row( + &format!( + "SELECT COUNT(*), COALESCE(SUM(embed_model IS NOT ?2), 0) FROM rag_chunks WHERE {}", + in_scope("?1") + ), + params![scope, model], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + Ok((rows, total, pending)) + }) + } + + /// Chunk rows for `ids` still inside `scope`, in the order asked for. + /// The ids were ranked a moment ago, so the scope is checked again in + /// case a document left it meanwhile. + pub fn rag_chunks_by_ids(&self, ids: &[i64], scope: &str) -> Result<Vec<ChunkRow>> { + self.with_read(|conn| { + let mut stmt = conn.prepare( + "SELECT id, doc_kind, doc_id, seq, text FROM rag_chunks + WHERE id = ?1 AND (doc_kind || ':' || doc_id) IN (SELECT value FROM json_each(?2))", + )?; + let mut out = Vec::with_capacity(ids.len()); + for id in ids { + if let Some(row) = stmt + .query_row(params![id, scope], |r| { + Ok(ChunkRow { + id: r.get(0)?, + doc_kind: r.get(1)?, + doc_id: r.get(2)?, + seq: r.get(3)?, + text: r.get(4)?, + }) + }) + .optional()? + { + out.push(row); + } + } + Ok(out) + }) + } + + /// Opening chunks of every document in `scope`, interleaved so each + /// document gets its first chunk before any gets its second. The + /// fallback when nothing in scope matches the query at all. + pub fn rag_leading_chunks(&self, scope: &str, per_doc: usize) -> Result<Vec<i64>> { + self.with_read(|conn| { + let mut stmt = conn.prepare(&format!( + "SELECT id FROM rag_chunks + WHERE seq < ?1 AND {} + ORDER BY seq, doc_kind, doc_id", + in_scope("?2") + ))?; + let rows = stmt + .query_map(params![per_doc as i64, scope], |r| r.get(0))? + .collect::<Result<Vec<_>, _>>()?; + Ok(rows) + }) + } +} + +/// Insert dataset members in order, skipping ids that aren't items. +fn insert_members(conn: &Connection, dataset_id: &str, item_ids: &[String]) -> Result<Vec<String>> { + let mut stored = Vec::with_capacity(item_ids.len()); + let mut stmt = conn.prepare( + "INSERT OR IGNORE INTO library_dataset_items (dataset_id, item_id, position) + SELECT ?1, id, ?3 FROM library_items WHERE id = ?2", + )?; + for item_id in item_ids { + if stmt.execute(params![dataset_id, item_id, stored.len() as i64])? > 0 { + stored.push(item_id.clone()); + } + } + Ok(stored) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn fresh_db() -> (Database, TempDir) { + let dir = TempDir::new().expect("tempdir"); + let db = Database::open(&dir.path().join("loach.db")).expect("open"); + db.migrate().expect("migrate"); + (db, dir) + } + + fn text_item(db: &Database, title: &str, content: &str) -> LibraryItemMeta { + db.create_library_item( + &NewLibraryItem { + kind: "text", + title, + mime: "text/markdown", + media: "text", + content, + size: content.len() as i64, + url: None, + fetched_at: None, + truncated: false, + }, + None, + ) + .expect("create item") + } + + fn scope(keys: &[(&str, &str)]) -> String { + let keys: Vec<String> = keys.iter().map(|(k, id)| format!("{k}:{id}")).collect(); + serde_json::to_string(&keys).unwrap() + } + + #[test] + fn items_are_chunked_and_keyword_searchable_within_scope() { + let (db, _dir) = fresh_db(); + let body = "The marketing budget for Q3 is forty thousand euros."; + let a = text_item(&db, "Budget", body); + let b = text_item(&db, "Recipes", "Pierogi need flour, water and a potato filling."); + assert_eq!(a.chunks, 1); + assert_eq!(a.content_hash, content_hash(body)); + assert!(a.preview.starts_with("The marketing")); + + let q = crate::rag::build_fts_query("what is the budget").unwrap(); + let both = scope(&[(DOC_LIBRARY, &a.id), (DOC_LIBRARY, &b.id)]); + assert_eq!(db.rag_keyword_search(&both, &q, 10).unwrap().len(), 1); + // Out of scope, the matching item is invisible. + let only_b = scope(&[(DOC_LIBRARY, &b.id)]); + assert!(db.rag_keyword_search(&only_b, &q, 10).unwrap().is_empty()); + // Diacritics fold, so an unaccented query finds accented text + // ("ł" is a letter of its own, not an accented "l", so it doesn't). + let c = text_item(&db, "PL", "Zażółć gęślą jaźń"); + let q = crate::rag::build_fts_query("gesla").unwrap(); + let only_c = scope(&[(DOC_LIBRARY, &c.id)]); + assert_eq!(db.rag_keyword_search(&only_c, &q, 10).unwrap().len(), 1); + } + + #[test] + fn editing_rechunks_and_deleting_drops_chunks() { + let (db, _dir) = fresh_db(); + let a = text_item(&db, "Note", "alpha beta"); + let s = scope(&[(DOC_LIBRARY, &a.id)]); + let alpha = crate::rag::build_fts_query("alpha").unwrap(); + let gamma = crate::rag::build_fts_query("gamma").unwrap(); + assert_eq!(db.rag_keyword_search(&s, &alpha, 5).unwrap().len(), 1); + + let meta = db + .replace_library_content(&a.id, Some("Renamed"), "gamma delta", None, 11, false, None, None) + .unwrap(); + assert_eq!(meta.title, "Renamed"); + assert!(db.rag_keyword_search(&s, &alpha, 5).unwrap().is_empty()); + assert_eq!(db.rag_keyword_search(&s, &gamma, 5).unwrap().len(), 1); + + db.delete_library_items(std::slice::from_ref(&a.id)).unwrap(); + assert_eq!(db.rag_counts(None).unwrap().0, 0); + assert!(db.rag_keyword_search(&s, &gamma, 5).unwrap().is_empty()); + } + + #[test] + fn datasets_keep_order_and_lose_deleted_items() { + let (db, _dir) = fresh_db(); + let a = text_item(&db, "A", "a body"); + let b = text_item(&db, "B", "b body"); + let ds = db + .create_library_dataset("Research", "", &[b.id.clone(), "missing".into(), a.id.clone()]) + .unwrap(); + assert_eq!(ds.item_ids, vec![b.id.clone(), a.id.clone()]); + + let stored = db.set_library_dataset_items(&ds.id, &[a.id.clone(), b.id.clone()]).unwrap(); + assert_eq!(stored, vec![a.id.clone(), b.id.clone()]); + + db.delete_library_items(std::slice::from_ref(&a.id)).unwrap(); + let listed = db.list_library_datasets().unwrap(); + assert_eq!(listed[0].item_ids, vec![b.id.clone()]); + + // Deleting the dataset leaves its items in the Library. + db.delete_library_dataset(&ds.id).unwrap(); + assert_eq!(db.list_library_items(None).unwrap().len(), 1); + } + + #[test] + fn sources_dedupe_cascade_and_report_usage() { + let (db, _dir) = fresh_db(); + let space = db.create_space("Thesis", "", "").unwrap(); + let chat = db.create_session("c", "ollama", "m", None, None).unwrap(); + let a = text_item(&db, "A", "a body"); + let ds = db.create_library_dataset("D", "", std::slice::from_ref(&a.id)).unwrap(); + + let ids = vec![a.id.clone()]; + let dsids = vec![ds.id.clone()]; + let first = db.add_library_sources(SourceOwner::Space, &space.id, &ids, &dsids).unwrap(); + assert_eq!(first.len(), 2); + // Linking the same things again is a no-op. + let again = db.add_library_sources(SourceOwner::Space, &space.id, &ids, &dsids).unwrap(); + assert_eq!(again.len(), 2); + db.add_library_sources(SourceOwner::Session, &chat.id, &ids, &[]).unwrap(); + + let usage = db.library_usage(&ids, &[]).unwrap(); + assert_eq!(usage.spaces, vec!["Thesis".to_string()]); + assert_eq!(usage.chats, 1); + + db.set_library_source_enabled(SourceOwner::Space, &first[0].id, false).unwrap(); + assert!(!db.list_library_sources(SourceOwner::Space, &space.id).unwrap()[0].enabled); + + // A fork carries the chat's sources with it. + let fork = db.fork_session(&chat.id, None).unwrap(); + assert_eq!(db.list_library_sources(SourceOwner::Session, &fork.id).unwrap().len(), 1); + + // Deleting the item unlinks it everywhere; deleting the Space drops + // its remaining links. + db.delete_library_items(&ids).unwrap(); + assert_eq!(db.list_library_sources(SourceOwner::Space, &space.id).unwrap().len(), 1); + assert!(db.list_library_sources(SourceOwner::Session, &chat.id).unwrap().is_empty()); + db.delete_space(&space.id).unwrap(); + assert!(db.all_library_sources(SourceOwner::Space).unwrap().is_empty()); + } + + #[test] + fn backfill_chunks_a_whole_restored_library_in_batches() { + let (db, _dir) = fresh_db(); + let ids: Vec<String> = (0..40) + .map(|i| text_item(&db, &format!("T{i}"), &format!("body number {i}")).id) + .collect(); + db.conn.lock().execute_batch("DELETE FROM rag_chunks").unwrap(); + // More documents than one batch, all chunked; then nothing left to do. + assert_eq!(db.backfill_rag_chunks().unwrap(), 40); + assert_eq!(db.backfill_rag_chunks().unwrap(), 0); + let q = crate::rag::build_fts_query("number").unwrap(); + let keys: Vec<(&str, &str)> = ids.iter().map(|id| (DOC_LIBRARY, id.as_str())).collect(); + assert_eq!(db.rag_keyword_search(&scope(&keys), &q, 100).unwrap().len(), 40); + } + + // Regression: one id deleted elsewhere failed the whole batch with a raw + // "FOREIGN KEY constraint failed", so none of the others were linked. + #[test] + fn linking_skips_ids_that_no_longer_exist() { + let (db, _dir) = fresh_db(); + let chat = db.create_session("c", "ollama", "m", None, None).unwrap(); + let a = text_item(&db, "A", "a body"); + let linked = db + .add_library_sources( + SourceOwner::Session, + &chat.id, + &[a.id.clone(), "gone-item".into()], + &["gone-dataset".into()], + ) + .unwrap(); + assert_eq!(linked.iter().map(|l| l.item_id.as_deref()).collect::<Vec<_>>(), vec![Some(a.id.as_str())]); + // A missing owner is still an error, not a silent no-op. + assert!(db.add_library_sources(SourceOwner::Session, "gone-chat", &[a.id.clone()], &[]).is_err()); + } + + #[test] + fn space_files_join_the_index_and_leave_with_their_space() { + let (db, _dir) = fresh_db(); + let space = db.create_space("S", "", "").unwrap(); + let f = db + .add_space_file(&space.id, "notes.md", "text/markdown", "text", "kangaroo facts", 14, 0) + .unwrap(); + db.add_space_file(&space.id, "pic.png", "image/png", "image", "iVBOR", 5, 1).unwrap(); + assert_eq!(db.rag_counts(None).unwrap().0, 1, "only the text file is chunked"); + let q = crate::rag::build_fts_query("kangaroo").unwrap(); + let s = scope(&[(DOC_SPACE_FILE, &f.id)]); + assert_eq!(db.rag_keyword_search(&s, &q, 5).unwrap().len(), 1); + + db.set_space_file_enabled(&f.id, false).unwrap(); + assert!(!db.list_space_files(&space.id).unwrap()[0].enabled); + + db.delete_space(&space.id).unwrap(); + assert_eq!(db.rag_counts(None).unwrap().0, 0); + } + + #[test] + fn embeddings_are_tracked_per_model() { + let (db, _dir) = fresh_db(); + text_item(&db, "A", "one"); + text_item(&db, "B", "two"); + let pending = db.rag_pending_embeddings("ollama:m1", 10).unwrap(); + assert_eq!(pending.len(), 2); + assert!(!pending[0].title.is_empty()); + + let rows: Vec<(i64, Vec<f32>)> = pending.iter().map(|c| (c.id, vec![1.0, 0.0])).collect(); + db.store_rag_embeddings("ollama:m1", &rows).unwrap(); + assert_eq!(db.rag_counts(Some("ollama:m1")).unwrap(), (2, 2)); + assert!(db.rag_pending_embeddings("ollama:m1", 10).unwrap().is_empty()); + // A different model sees everything as pending again. + assert_eq!(db.rag_pending_embeddings("ollama:m2", 10).unwrap().len(), 2); + + db.clear_rag_embeddings().unwrap(); + assert_eq!(db.rag_counts(Some("ollama:m1")).unwrap(), (2, 0)); + } + + #[test] + fn refused_chunks_are_set_aside_until_the_model_changes() { + let (db, _dir) = fresh_db(); + let a = text_item(&db, "A", "one"); + let pending = db.rag_pending_embeddings("ollama:m1", 10).unwrap(); + db.skip_rag_chunks("ollama:m1", &[pending[0].id]).unwrap(); + + // Done for this model: no longer pending, counted, but no vector. + assert!(db.rag_pending_embeddings("ollama:m1", 10).unwrap().is_empty()); + assert_eq!(db.rag_counts(Some("ollama:m1")).unwrap(), (1, 1)); + let s = scope(&[(DOC_LIBRARY, &a.id)]); + let (vectors, total, left) = db.rag_vectors(&s, "ollama:m1").unwrap(); + assert_eq!((vectors.len(), total, left), (0, 1, 0)); + // Another model gives it another try. + assert_eq!(db.rag_pending_embeddings("ollama:m2", 10).unwrap().len(), 1); + } + + // Regression: a chunk that failed every time held the lowest ids, so the + // indexer asked for the same batch every minute and nothing after it was + // ever embedded. + #[test] + fn a_chunk_set_aside_lets_the_queue_move_on_until_retried() { + let (db, _dir) = fresh_db(); + let m = "ollama:m1"; + let a = text_item(&db, "A", "poison"); + let b = text_item(&db, "B", "fine"); + let first = db.rag_pending_embeddings(m, 1).unwrap(); + db.skip_rag_chunks(m, &[first[0].id]).unwrap(); + assert_eq!(db.rag_pending_embeddings(m, 1).unwrap()[0].text, "fine", "the queue moved on"); + db.store_rag_embeddings(m, &in_flight(&db, m)).unwrap(); + // Not pending in the status, nor in a search's "still indexing". + assert_eq!(db.rag_counts(Some(m)).unwrap(), (2, 2)); + let s = scope(&[(DOC_LIBRARY, &a.id), (DOC_LIBRARY, &b.id)]); + let (vectors, total, left) = db.rag_vectors(&s, m).unwrap(); + assert_eq!((vectors.len(), total, left), (1, 2, 0)); + + // New settings for the same model: only the set-aside chunk is redone. + db.retry_skipped_rag_chunks(m).unwrap(); + let again = db.rag_pending_embeddings(m, 10).unwrap(); + assert_eq!(again.iter().map(|c| c.text.as_str()).collect::<Vec<_>>(), vec!["poison"]); + // A rebuild redoes everything, set-aside chunks included. + db.skip_rag_chunks(m, &[again[0].id]).unwrap(); + db.clear_rag_embeddings().unwrap(); + assert_eq!(db.rag_counts(Some(m)).unwrap(), (2, 0)); + } + + // Development indexes keyed before the key named the server, like a + // switch to another server, leave chunks under an old key: nothing done + // under it — vectors or chunks set aside — may count as done under the + // new one. + #[test] + fn a_new_key_requeues_vectors_and_set_aside_chunks() { + let (db, _dir) = fresh_db(); + let (old, new) = ("ollama:m1", "ollama:m1@http://localhost:11434"); + let a = text_item(&db, "A", "one"); + let b = text_item(&db, "B", "two"); + let pending = db.rag_pending_embeddings(old, 10).unwrap(); + db.store_rag_embeddings(old, &[(pending[0].id, vec![1.0])]).unwrap(); + db.skip_rag_chunks(old, &[pending[1].id]).unwrap(); + assert_eq!(db.rag_counts(Some(old)).unwrap(), (2, 2)); + + assert_eq!(db.rag_counts(Some(new)).unwrap(), (2, 0)); + assert_eq!(db.rag_pending_embeddings(new, 10).unwrap().len(), 2); + let s = scope(&[(DOC_LIBRARY, &a.id), (DOC_LIBRARY, &b.id)]); + let (vectors, total, left) = db.rag_vectors(&s, new).unwrap(); + assert_eq!((vectors.len(), total, left), (0, 2, 2)); + assert_eq!(db.library_item_meta(&a.id, Some(new)).unwrap().embedded, 0); + } + + #[test] + fn renaming_returns_the_row_and_re_embeds_only_on_change() { + let (db, _dir) = fresh_db(); + let a = text_item(&db, "Old", "body"); + let ids: Vec<(i64, Vec<f32>)> = db + .rag_pending_embeddings("ollama:m1", 10) + .unwrap() + .iter() + .map(|c| (c.id, vec![1.0])) + .collect(); + db.store_rag_embeddings("ollama:m1", &ids).unwrap(); + + // Same title: nothing to redo. + db.rename_library_item(&a.id, "Old", Some("ollama:m1")).unwrap(); + assert!(db.rag_pending_embeddings("ollama:m1", 10).unwrap().is_empty()); + + let meta = db.rename_library_item(&a.id, "New", Some("ollama:m1")).unwrap(); + assert_eq!(meta.title, "New"); + assert_eq!(meta.embedded, 0); + let pending = db.rag_pending_embeddings("ollama:m1", 10).unwrap(); + assert_eq!(pending[0].title, "New"); + } + + /// Ids of chunks still waiting for `model`, as an indexer batch reads them. + fn in_flight(db: &Database, model: &str) -> Vec<ChunkVector> { + db.rag_pending_embeddings(model, 100) + .unwrap() + .iter() + .map(|c| (c.id, vec![1.0])) + .collect() + } + + // Regression: `rag_chunks.id` had no AUTOINCREMENT, so re-chunking the + // newest document handed its deleted ids straight to the new chunks, and + // an embedding in flight for the old text was stored on the new text. + #[test] + fn an_embedding_in_flight_never_lands_on_newer_text() { + let (db, _dir) = fresh_db(); + let m = "ollama:m1"; + let a = text_item(&db, "Note", "old text about otters"); + let stale = in_flight(&db, m); + + // Edited while the batch is out: the vectors for the old text must + // not be stored on the new chunks. + db.replace_library_content(&a.id, None, "new text about badgers", None, 22, false, None, Some(m)) + .unwrap(); + db.store_rag_embeddings(m, &stale).unwrap(); + assert_eq!(db.rag_counts(Some(m)).unwrap(), (1, 0), "edited chunk still pending"); + + // Deleted, then another document added: same story. + let stale = in_flight(&db, m); + db.delete_library_items(std::slice::from_ref(&a.id)).unwrap(); + let b = text_item(&db, "Other", "unrelated text"); + db.skip_rag_chunks(m, &stale.iter().map(|r| r.0).collect::<Vec<_>>()).unwrap(); + db.store_rag_embeddings(m, &stale).unwrap(); + assert_eq!(db.rag_counts(Some(m)).unwrap(), (1, 0), "new document's chunk still pending"); + assert_eq!(db.library_item_meta(&b.id, Some(m)).unwrap().embedded, 0); + } + + // Regression: rename and Rebuild reset embeddings in place, so a batch in + // flight from before (old title, old model) was stored right over the + // reset and the chunk counted as done. + #[test] + fn rename_and_rebuild_outrun_a_batch_in_flight() { + let (db, _dir) = fresh_db(); + let m = "ollama:m1"; + let a = text_item(&db, "Old title", "body about otters"); + let s = scope(&[(DOC_LIBRARY, &a.id)]); + + let stale = in_flight(&db, m); + db.rename_library_item(&a.id, "New title", Some(m)).unwrap(); + db.store_rag_embeddings(m, &stale).unwrap(); + assert_eq!(db.rag_counts(Some(m)).unwrap(), (1, 0), "renamed chunk still pending"); + + // Embed it for real, then Rebuild while a stale batch is out. + db.store_rag_embeddings(m, &in_flight(&db, m)).unwrap(); + let stale: Vec<ChunkVector> = + db.rag_vectors(&s, m).unwrap().0.into_iter().map(|(id, _)| (id, vec![0.5])).collect(); + db.clear_rag_embeddings().unwrap(); + db.store_rag_embeddings(m, &stale).unwrap(); + assert_eq!(db.rag_counts(Some(m)).unwrap(), (1, 0), "rebuilt chunk still pending"); + + // The keyword index followed the chunks to their new ids. + let q = crate::rag::build_fts_query("otters").unwrap(); + let hits = db.rag_keyword_search(&s, &q, 5).unwrap(); + assert_eq!(db.rag_chunks_by_ids(&hits, &s).unwrap()[0].text, "body about otters"); + } + + // Rebuild used to copy and delete the whole index in one transaction, + // holding every send's writes for its length. A batch at a time, each + // one — and a rebuild cut short between them — must leave every chunk in + // the index exactly once. + #[test] + fn a_rebuild_moves_every_chunk_once_batch_by_batch() { + let (db, _dir) = fresh_db(); + let m = "ollama:m1"; + let n = REISSUE_BATCH * 2 + 7; + db.conn + .lock() + .execute( + "WITH RECURSIVE s(i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM s WHERE i + 1 < ?1) + INSERT INTO rag_chunks (doc_kind, doc_id, seq, text) + SELECT 'library', 'doc', i, 'excerpt ' || i FROM s", + params![n], + ) + .unwrap(); + let stale: Vec<ChunkVector> = + db.rag_pending_embeddings(m, n as usize).unwrap().iter().map(|c| (c.id, vec![1.0])).collect(); + db.store_rag_embeddings(m, &stale).unwrap(); + let first = last_chunk_id(&db.conn.lock()).unwrap() - n + 1; + let q = crate::rag::build_fts_query("excerpt").unwrap(); + let s = scope(&[(DOC_LIBRARY, "doc")]); + let hits = |db: &Database| db.rag_keyword_search(&s, &q, n as usize + 1).unwrap().len() as i64; + + // Cut short after one batch: every chunk still there once, the keyword + // index in step, the rest still on their old vectors. + reissue_chunks(&db.conn.lock(), None, first + REISSUE_BATCH - 1).unwrap(); + assert_eq!(db.rag_counts(Some(m)).unwrap(), (n, n - REISSUE_BATCH)); + assert_eq!(hits(&db), n); + + db.clear_rag_embeddings().unwrap(); + assert_eq!(db.rag_counts(Some(m)).unwrap(), (n, 0)); + assert_eq!(hits(&db), n); + let distinct: i64 = db + .conn + .lock() + .query_row("SELECT COUNT(DISTINCT seq) FROM rag_chunks", [], |r| r.get(0)) + .unwrap(); + assert_eq!(distinct, n, "each chunk once"); + // A batch in flight from before lands nowhere. + db.store_rag_embeddings(m, &stale).unwrap(); + assert_eq!(db.rag_counts(Some(m)).unwrap(), (n, 0)); + } + + // Regression: the scope was matched as `doc_kind || ':' || doc_id`, so + // every search read the whole index, past each chunk's embedding. + #[test] + fn scoped_reads_look_documents_up_by_index() { + let (db, _dir) = fresh_db(); + let m = "ollama:m1"; + let a = text_item(&db, "A", &"alpha ".repeat(400)); + let b = text_item(&db, "B", "beta"); + text_item(&db, "C", "gamma"); + db.store_rag_embeddings(m, &in_flight(&db, m)).unwrap(); + assert!(a.chunks >= 2); + + // Out-of-scope documents, a document listed twice and a key of the + // wrong kind change nothing. + let s = scope(&[(DOC_LIBRARY, &a.id), (DOC_LIBRARY, &b.id), (DOC_LIBRARY, &b.id), (DOC_SPACE_FILE, &a.id)]); + let (vectors, total, left) = db.rag_vectors(&s, m).unwrap(); + assert_eq!((vectors.len() as i64, total, left), (a.chunks + 1, a.chunks + 1, 0)); + let lead = db.rag_leading_chunks(&s, 2).unwrap(); + let rows = db.rag_chunks_by_ids(&lead, &s).unwrap(); + assert_eq!(rows.iter().map(|r| r.seq).collect::<Vec<_>>(), vec![0, 0, 1]); + assert_eq!(rows[2].doc_id, a.id); + + let plan: Vec<String> = { + let conn = db.conn.lock(); + let mut stmt = conn + .prepare(&format!("EXPLAIN QUERY PLAN SELECT id FROM rag_chunks WHERE {}", in_scope("?1"))) + .unwrap(); + stmt.query_map(params![s], |r| r.get(3)).unwrap().collect::<Result<_, _>>().unwrap() + }; + assert!(plan.iter().any(|d| d.contains("INDEX idx_rag_chunks_doc (doc_kind=? AND doc_id=?)")), "{plan:?}"); + } + + // Regression: the "embedded with this model" counts — per item in the + // Library list, per scope in a search — read each chunk past its + // embedding BLOB to reach `embed_model`, where an index answers them. + #[test] + fn embedded_counts_are_read_from_an_index() { + let (db, _dir) = fresh_db(); + let rag_vectors_count = format!( + "SELECT COUNT(*), COALESCE(SUM(embed_model IS NOT ?2), 0) FROM rag_chunks WHERE {}", + in_scope("?1") + ); + let cases: [(&str, &[&dyn rusqlite::ToSql]); 2] = [ + (ITEM_META_SELECT, params!["ollama:m1"]), + (&rag_vectors_count, params!["[]", "ollama:m1"]), + ]; + for (sql, args) in cases { + let conn = db.conn.lock(); + let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}")).unwrap(); + let plan: Vec<String> = + stmt.query_map(args, |r| r.get(3)).unwrap().collect::<Result<_, _>>().unwrap(); + assert!( + plan.iter().any(|d| d.contains("COVERING INDEX idx_rag_chunks_doc_model")), + "{sql}: {plan:?}" + ); + } + } + + #[test] + fn chunk_rows_are_read_back_only_within_scope() { + let (db, _dir) = fresh_db(); + let a = text_item(&db, "A", "alpha"); + let b = text_item(&db, "B", "beta"); + let both = scope(&[(DOC_LIBRARY, &a.id), (DOC_LIBRARY, &b.id)]); + let ids: Vec<i64> = db.rag_leading_chunks(&both, 1).unwrap(); + assert_eq!(ids.len(), 2); + let only_a = scope(&[(DOC_LIBRARY, &a.id)]); + let rows = db.rag_chunks_by_ids(&ids, &only_a).unwrap(); + assert_eq!(rows.iter().map(|r| r.text.as_str()).collect::<Vec<_>>(), vec!["alpha"]); + } + + #[test] + fn a_pre_release_index_without_autoincrement_is_rebuilt() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("loach.db"); + { + let db = Database::open(&path).expect("open"); + db.migrate().expect("migrate"); + text_item(&db, "Kept", "a document from a development build"); + // The table as development builds of 1.6 created it. + db.conn + .lock() + .execute_batch( + "DROP TABLE rag_chunks_fts; DROP TABLE rag_chunks; + CREATE TABLE rag_chunks ( + id INTEGER PRIMARY KEY, doc_kind TEXT NOT NULL, doc_id TEXT NOT NULL, + seq INTEGER NOT NULL, text TEXT NOT NULL, embedding BLOB, embed_model TEXT + );", + ) + .expect("simulate the old table"); + } + let db = Database::open(&path).expect("reopen"); + db.migrate().expect("migrate rebuilds the index"); + let sql: String = db + .conn + .lock() + .query_row("SELECT sql FROM sqlite_master WHERE name = 'rag_chunks'", [], |r| r.get(0)) + .unwrap(); + assert!(sql.contains("AUTOINCREMENT"), "{sql}"); + // The indexer's backfill re-chunks the documents into the new table. + assert_eq!(db.backfill_rag_chunks().unwrap(), 1); + let q = crate::rag::build_fts_query("development").unwrap(); + let all = scope(&[(DOC_LIBRARY, &db.list_library_items(None).unwrap()[0].id)]); + assert_eq!(db.rag_keyword_search(&all, &q, 5).unwrap().len(), 1); + db.migrate().expect("and stays idempotent"); + } + + #[test] + fn snapshot_round_trips_library_and_index_is_rebuilt() { + let (db, _dir) = fresh_db(); + let space = db.create_space("S", "", "").unwrap(); + db.set_space_retrieval_mode(&space.id, "retrieval").unwrap(); + let a = text_item(&db, "A", "walrus migration patterns"); + let ds = db.create_library_dataset("D", "desc", std::slice::from_ref(&a.id)).unwrap(); + db.add_library_sources(SourceOwner::Space, &space.id, &[], std::slice::from_ref(&ds.id)) + .unwrap(); + + let json = serde_json::to_string(&db.snapshot().unwrap()).unwrap(); + // The export never carries the derived index. + assert!(!json.contains("rag_chunks") && !json.contains("embedding")); + + db.wipe_user_data().unwrap(); + assert!(db.list_library_items(None).unwrap().is_empty()); + assert_eq!(db.rag_counts(None).unwrap().0, 0); + + let snap: crate::db::DatabaseSnapshot = serde_json::from_str(&json).unwrap(); + let stats = db.restore_snapshot(&snap).unwrap(); + assert_eq!((stats.library_items, stats.library_datasets), (1, 1)); + assert_eq!(db.list_library_datasets().unwrap()[0].item_ids, vec![a.id.clone()]); + assert_eq!(db.list_library_sources(SourceOwner::Space, &space.id).unwrap().len(), 1); + assert_eq!(db.get_space(&space.id).unwrap().unwrap().retrieval_mode, "retrieval"); + + // Restored bodies have no chunks until the indexer backfills them. + assert_eq!(db.rag_counts(None).unwrap().0, 0); + assert_eq!(db.backfill_rag_chunks().unwrap(), 1); + assert_eq!(db.rag_counts(None).unwrap().0, 1); + assert_eq!(db.backfill_rag_chunks().unwrap(), 0); + } + + #[test] + fn older_snapshots_without_library_fields_still_load() { + let (db, _dir) = fresh_db(); + let mut v = serde_json::to_value(db.snapshot().unwrap()).unwrap(); + let data = v["data"].as_object_mut().unwrap(); + for k in [ + "library_items", + "library_datasets", + "space_library_sources", + "session_library_sources", + ] { + data.remove(k); + } + let snap: crate::db::DatabaseSnapshot = serde_json::from_value(v).unwrap(); + db.restore_snapshot(&snap).unwrap(); + } +} diff --git a/src-tauri/src/gpu.rs b/src-tauri/src/gpu.rs index 0e530bc..de5b2c8 100644 --- a/src-tauri/src/gpu.rs +++ b/src-tauri/src/gpu.rs @@ -206,6 +206,12 @@ fn amdgpu_sysfs() -> Option<GpuInfo> { .filter_map(|e| { let total = e.path().join("device/mem_info_vram_total"); let bytes: u64 = std::fs::read_to_string(&total).ok()?.trim().parse().ok()?; + // The Linux side of `is_unified_memory`: a Ryzen APU reports its + // 2–4 GB UMA carve-out here as VRAM, exactly as it does to DXGI. + let metrics = std::fs::read(e.path().join("device/gpu_metrics")).unwrap_or_default(); + if is_apu_metrics(&metrics) { + return None; + } // No reliable model string here; the vendor name is honest and // enough for a "based on N GB VRAM" line. Some(GpuInfo { @@ -216,6 +222,17 @@ fn amdgpu_sysfs() -> Option<GpuInfo> { .max_by_key(|g| g.vram_bytes) } +/// Whether an amdgpu `gpu_metrics` blob comes from an APU. Its header is +/// `structure_size: u16, format_revision: u8, content_revision: u8`, and the +/// format revision names the layout: 1 is the discrete one, 2 and 3 the APU +/// ones, which carry per-CPU-core fields. Anything else — no file (older +/// ASICs, a driver without it), a short read, a revision this predates — +/// counts as discrete, which is the behaviour that shipped before. +#[cfg(any(target_os = "linux", test))] +fn is_apu_metrics(metrics: &[u8]) -> bool { + matches!(metrics.get(2), Some(2 | 3)) +} + /// macOS: unified memory. See the module docs — reporting VRAM separately here /// would double-count the RAM the caller already sizes against. #[cfg(not(any(windows, target_os = "linux")))] @@ -227,6 +244,19 @@ fn detect_impl() -> Option<GpuInfo> { mod tests { use super::*; + /// Only the APU layouts read as integrated; everything unreadable stays + /// discrete. + #[test] + fn apu_metrics_are_told_apart_by_format_revision() { + // structure_size (LE), format_revision, content_revision, … + assert!(is_apu_metrics(&[0x60, 0x00, 2, 1, 0, 0]), "v2.x, an APU"); + assert!(is_apu_metrics(&[0x00, 0x01, 3, 0]), "v3.x, an APU"); + assert!(!is_apu_metrics(&[0x78, 0x00, 1, 3, 0, 0]), "v1.x, a discrete card"); + assert!(!is_apu_metrics(&[]), "no gpu_metrics file"); + assert!(!is_apu_metrics(&[0x60, 0x00]), "short read"); + assert!(!is_apu_metrics(&[0x60, 0x00, 4, 0]), "a revision this predates"); + } + /// `detect()` is hardware-dependent, so this asserts the invariants rather /// than a value: on a machine with no readable GPU it passes trivially, and /// on one with a GPU it catches the failure mode that matters — FFI that diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0c104e..067127f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,8 @@ mod mcp; mod ollama_launch; mod preload; mod providers; +mod rag; +mod sandbox; mod secrets; mod security; mod sse; @@ -18,6 +20,7 @@ use tauri::{ tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, Manager, }; +#[cfg(not(target_os = "linux"))] use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; use crate::db::Database; @@ -38,6 +41,28 @@ fn fatal_setup_error(app: &tauri::AppHandle, what: &str, err: impl std::fmt::Dis // the dialog can't be shown (very headless CI, broken display) this // returns immediately — we still exit afterwards so the app doesn't // limp on with broken state. + // + // Not on Linux: `setup` runs inside tao's event loop, which holds GLib's + // default main context for as long as it runs, and rfd's GTK dialog is + // created from an idle callback on that context — so with this thread + // blocked waiting for it (here, or on any other thread we joined) it never + // appeared and the window hung blank. `gtk_dialog_run` spins a nested + // loop on this thread instead, which owns the context. + #[cfg(target_os = "linux")] + { + use gtk::prelude::*; + let _ = app; // the plugin's dialog is the one path that needs it + let dialog = gtk::MessageDialog::new( + None::<>k::Window>, + gtk::DialogFlags::MODAL, + gtk::MessageType::Error, + gtk::ButtonsType::Ok, + &body, + ); + dialog.set_title("Loach failed to start"); + dialog.run(); + } + #[cfg(not(target_os = "linux"))] let _ = app .dialog() .message(&body) @@ -62,6 +87,9 @@ pub struct AppState { /// test-then-save asks once. Deliberately not persisted: a fresh /// process re-asks before anything runs. pub stdio_approved: parking_lot::Mutex<std::collections::HashSet<String>>, + /// Background worker keeping the Library / Space-source retrieval index + /// chunked and embedded. Woken by anything that can create work. + pub indexer: Arc<rag::indexer::Indexer>, } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -86,10 +114,23 @@ pub fn run() { .init(); tauri::Builder::default() + // First, as the plugin requires. A second launch (Start menu while + // hidden to the tray, a desktop file, a double click on the app) + // brings the running window back and exits, instead of a second + // process sharing loach.db, running its own indexer and MCP servers, + // and overwriting edits made in the other window. + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.unminimize(); + let _ = w.set_focus(); + } + })) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + .register_uri_scheme_protocol(sandbox::SCHEME, |_ctx, request| sandbox::handle(&request)) .setup(|app| { // Replace the prior `.expect(...)` chain with explicit user- // facing error dialogs. The old behaviour silent-crashed the @@ -117,6 +158,8 @@ pub fn run() { ); } + security::init_marker(&data_dir); + let db_path = data_dir.join("loach.db"); let db = match Database::open(&db_path) { Ok(d) => d, @@ -183,6 +226,7 @@ pub fn run() { let db = Arc::new(db); let preload_db = db.clone(); let preload_http = http.clone(); + let indexer = rag::indexer::Indexer::spawn(handle.clone(), db.clone(), http.clone()); let state = AppState { db, @@ -191,6 +235,7 @@ pub fn run() { mcp_tools_cache: crate::mcp::new_tools_cache(), approvals: crate::stream::ApprovalRegistry::new(), stdio_approved: Default::default(), + indexer, }; app.manage(state); @@ -211,8 +256,11 @@ pub fn run() { // macOS menu-bar icons render as template images — single-channel // masks taken from the alpha channel — so they auto-invert in - // light vs. dark menu bars. The flag is a no-op on Windows / - // Linux, but we still gate it so the intent reads clearly. + // light vs. dark menu bars. That rules out the app icon there: it + // is an opaque rounded square, so its mask is a solid blob. macOS + // gets `icons/tray-template.png` instead, the fish line art in + // black on transparent. It is 36 px tall because tray-icon draws + // the image 18 pt tall, so it maps 2:1 onto a Retina menu bar. let _tray = { let builder = TrayIconBuilder::with_id("loach-tray") .menu(&menu) @@ -223,12 +271,15 @@ pub fn run() { // ships without the icon degrades to an icon-less tray instead // of panicking the whole `setup` (which would bypass the // friendly `fatal_setup_error` dialog the rest of setup uses). + #[cfg(not(target_os = "macos"))] let builder = match app.default_window_icon() { Some(icon) => builder.icon(icon.clone()), None => builder, }; #[cfg(target_os = "macos")] - let builder = builder.icon_as_template(true); + let builder = builder + .icon(tauri::include_image!("icons/tray-template.png")) + .icon_as_template(true); builder .on_menu_event(|app, event| match event.id.as_ref() { "show" => { @@ -268,6 +319,45 @@ pub fn run() { .build(app)? }; + // macOS: closing the window (red button, ⌘W) hides it and Loach + // stays in the Dock, like other Mac apps; the Dock icon (the + // `Reopen` arm below) or the tray brings it back. ⌘Q and the + // tray's Quit still quit — they end the app without a close + // request. Windows and Linux keep close-means-quit. + #[cfg(target_os = "macos")] + { + use tauri::Emitter; + if let Some(window) = app.get_webview_window("main") { + let w = window.clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + // Closing used to quit, and every launch meets the + // app lock; the renderer locks on this when one is + // set (`useAutoLock`), so a close still locks. + let _ = w.emit("window://close-hidden", ()); + // Hiding in the same breath left the renderer no + // frame to paint the lock screen in, so a Dock + // reopen could flash the unlocked chat first. Give + // it a moment, then hide. + let fullscreen = w.is_fullscreen().unwrap_or(false); + let w = w.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + if fullscreen { + // Hiding a full-screen window leaves its + // Space behind, empty and black. Hiding + // the app (⌘H) steps out of it properly. + let _ = w.app_handle().hide(); + } else { + let _ = w.hide(); + } + }); + } + }); + } + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -283,6 +373,7 @@ pub fn run() { commands::update_session_params, commands::update_session_label, commands::set_session_folder, + commands::set_session_space, commands::pick_session_workspace, commands::clear_session_workspace, commands::open_session_workspace, @@ -318,6 +409,7 @@ pub fn run() { commands::ollama_probe, commands::ollama_start, commands::openai_list_models, + commands::openai_list_models_with_key, commands::chat_stream, commands::chat_cancel, commands::chat_cancel_all, @@ -356,6 +448,31 @@ pub fn run() { commands::list_snippet_fill_values, commands::upsert_snippet_fill_values, commands::fetch_url, + commands::library_list, + commands::library_get_items, + commands::library_create_item, + commands::library_rename_item, + commands::library_update_text, + commands::library_add_link, + commands::library_refresh_link, + commands::library_delete_items, + commands::library_usage, + commands::library_create_dataset, + commands::library_update_dataset, + commands::library_set_dataset_items, + commands::library_delete_dataset, + commands::library_list_sources, + commands::library_add_sources, + commands::library_remove_source, + commands::library_set_source_enabled, + commands::set_space_file_enabled, + commands::set_space_retrieval_mode, + commands::set_session_retrieval_mode, + commands::set_space_image_scaling, + commands::set_session_image_scaling, + commands::rag_search, + commands::rag_status, + commands::rag_reindex, commands::mcp_list, commands::mcp_save, commands::mcp_delete, @@ -363,7 +480,7 @@ pub fn run() { commands::mcp_tools, commands::tool_approval_respond, commands::storage_stats, - commands::export_data_json, + commands::export_data_to_file, commands::import_data_with_dialog, commands::save_text_to_file, commands::save_binary_to_file, @@ -376,13 +493,44 @@ pub fn run() { ]) .build(tauri::generate_context!()) .expect("error while building Loach") - .run(|_app, event| { - if let tauri::RunEvent::Exit = event { - // Pooled stdio MCP servers are our child processes. Kill - // them now rather than rely on each one noticing its stdin - // closing once we're gone. + .run(|_app, event| match event { + tauri::RunEvent::Exit => { + // Pooled stdio MCP servers are our child processes. Dropping + // the sessions closes their stdin; `kill_all_now` then gives + // them a shared second to exit on their own and kills + // whatever is left, rather than relying on each one noticing + // once we're gone. crate::mcp::drop_all_sessions(); crate::mcp::stdio::kill_all_now(); } + // A Dock click while the window is hidden to the tray. macOS + // brings back minimized windows itself, but not hidden ones, so + // without this the click did nothing. + #[cfg(target_os = "macos")] + tauri::RunEvent::Reopen { + has_visible_windows: false, + .. + } => { + if let Some(w) = _app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } + } + _ => {} }); } + +#[cfg(test)] +mod tests { + /// macOS draws the tray icon as a template, from the alpha channel alone + /// and 18 pt tall, so the file must stay black on transparent and 36 px + /// (2x) tall. Off macOS this is also the only place the `include_image!` + /// path is compiled. + #[test] + fn tray_template_is_a_retina_menu_bar_template() { + let icon = tauri::include_image!("icons/tray-template.png"); + assert_eq!(icon.height(), 36); + assert!(icon.rgba().chunks(4).all(|px| px[..3] == [0, 0, 0])); + assert!(icon.rgba().chunks(4).any(|px| px[3] == 255)); + } +} diff --git a/src-tauri/src/mcp/client.rs b/src-tauri/src/mcp/client.rs index 1d4535d..6347dfc 100644 --- a/src-tauri/src/mcp/client.rs +++ b/src-tauri/src/mcp/client.rs @@ -6,11 +6,13 @@ use futures_util::StreamExt; use serde_json::{json, Value}; use crate::db::McpServer; -use crate::sse::find_frame_end; +use crate::sse::{find_frame_end, frame_lines}; +use super::stdio::{classify, server_request_reply, Incoming}; use super::types::{ Attachment, CallToolContent, CallToolResult, InitializeResult, JsonRpcError, JsonRpcResponse, - ListToolsResult, McpCallResult, McpTestResult, McpTool, McpToolRaw, PROTOCOL_VERSION, + ListToolsResult, McpCallResult, McpTestResult, McpTool, McpToolRaw, MAX_TOOL_CALL_TIME, + MAX_TOOL_PAGES, PROTOCOL_VERSION, }; /// Per-request ceiling. Remote MCP servers can take a few seconds to cold @@ -40,8 +42,31 @@ impl std::fmt::Display for PreExecutionFailure { impl std::error::Error for PreExecutionFailure {} /// True when `err` was tagged [`PreExecutionFailure`] anywhere in its chain. +/// +/// `downcast_ref`, not `chain()`: the tag is always attached with +/// `.context(…)`, and `chain()` hands a context layer back as anyhow's own +/// wrapper type, so the tag was never found and no failure was ever retried. pub fn is_pre_execution(err: &anyhow::Error) -> bool { - err.chain().any(|c| c.is::<PreExecutionFailure>()) + err.downcast_ref::<PreExecutionFailure>().is_some() +} + +/// A JSON-RPC `error` reply — invalid params, an unknown tool. The server +/// got the request and answered it, so unlike a transport failure it says +/// nothing against the session it came over (see `mcp::keeps_session`). +#[derive(Debug)] +pub struct RpcError(JsonRpcError); + +impl std::fmt::Display for RpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format_rpc_error(&self.0)) + } +} + +impl std::error::Error for RpcError {} + +/// True when `err` is an [`RpcError`] reply. +pub fn is_rpc_error(err: &anyhow::Error) -> bool { + err.chain().any(|c| c.is::<RpcError>()) } /// Cap on the response body we'll buffer per JSON-RPC call. MCP responses @@ -113,9 +138,14 @@ pub struct McpSession { url: String, headers: HashMap<String, String>, session_id: Option<String>, + /// The protocol version `initialize` settled on, sent back as + /// `MCP-Protocol-Version` on every request after it, as the spec asks of + /// HTTP clients. `None` before that, or when the server named none (or + /// something that can't go in a header). + protocol_version: Option<String>, /// Monotonic JSON-RPC `id` counter. Streamable HTTP is one-POST-one- - /// reply so we don't pair replies to pending requests, but a fresh id - /// per call keeps the logs and any server-side telemetry sane. + /// reply, but the reply may share its SSE stream with the server's own + /// messages, so it is told apart by this id. next_id: i64, } @@ -135,6 +165,7 @@ impl McpSession { url: url.to_string(), headers, session_id: None, + protocol_version: None, next_id: 1, }) } @@ -158,18 +189,16 @@ impl McpSession { "clientInfo": { "name": CLIENT_NAME, "version": CLIENT_VERSION }, }, }); - let (resp, sid) = post_rpc( - &self.http, - &self.url, - &self.headers, - &init, - self.session_id.as_deref(), - ) - .await?; + let (resp, sid) = post_rpc(self, &init).await?; if let Some(s) = accept_session_id(sid) { self.session_id = Some(s); } let parsed: InitializeResult = unwrap_response(resp)?; + self.protocol_version = parsed.protocol_version.clone().filter(|v| { + !v.is_empty() + && v.len() <= 64 + && v.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.') + }); // Best-effort initialized notification. Some servers (notably the // GitHub gateway) require this before they'll honour tools/* calls. @@ -177,40 +206,44 @@ impl McpSession { "jsonrpc": "2.0", "method": "notifications/initialized", }); - let _ = post_rpc_raw( - &self.http, - &self.url, - &self.headers, - ¬e, - self.session_id.as_deref(), - ) - .await; + let _ = post_rpc_raw(self, ¬e).await; Ok(parsed) } - /// Fetch the full tool catalog. Returns the raw shape (includes the - /// JSON-Schema for each tool's arguments) so callers that need to - /// forward schemas to an LLM don't lose them. + /// Fetch the full tool catalog, every page of it. Returns the raw shape + /// (includes the JSON-Schema for each tool's arguments) so callers that + /// need to forward schemas to an LLM don't lose them. pub async fn list_tools_raw(&mut self) -> Result<Vec<McpToolRaw>> { - let body = json!({ - "jsonrpc": "2.0", - "id": self.fresh_id(), - "method": "tools/list", - }); - let (resp, sid) = post_rpc( - &self.http, - &self.url, - &self.headers, - &body, - self.session_id.as_deref(), - ) - .await?; - if let Some(s) = accept_session_id(sid) { - self.session_id = Some(s); + let mut tools = Vec::new(); + let mut cursor: Option<String> = None; + for page in 1.. { + let mut body = json!({ + "jsonrpc": "2.0", + "id": self.fresh_id(), + "method": "tools/list", + }); + if let Some(c) = cursor.take() { + body["params"] = json!({ "cursor": c }); + } + let (resp, sid) = post_rpc(self, &body).await?; + if let Some(s) = accept_session_id(sid) { + self.session_id = Some(s); + } + let parsed: ListToolsResult = unwrap_response(resp)?; + tools.extend(parsed.tools); + match parsed.next_cursor.filter(|c| !c.is_empty()) { + None => break, + Some(_) if page >= MAX_TOOL_PAGES => { + tracing::warn!( + "MCP: tools/list still paging after {MAX_TOOL_PAGES} pages — keeping what's in" + ); + break; + } + next => cursor = next, + } } - let parsed: ListToolsResult = unwrap_response(resp)?; - Ok(parsed.tools) + Ok(tools) } /// Invoke a single tool. Returns the concatenated text content + the @@ -219,23 +252,21 @@ impl McpSession { /// without validating against the schema (the server will reject /// invalid shapes itself and the model will react to the error). pub async fn call_tool(&mut self, name: &str, arguments: &Value) -> Result<McpCallResult> { + let id = self.fresh_id(); let body = json!({ "jsonrpc": "2.0", - "id": self.fresh_id(), + "id": id, "method": "tools/call", "params": { "name": name, "arguments": arguments, + // Asks for progress under the call's own id; a long tool that + // reports it keeps the call's deadline moving (see + // `post_rpc_raw`). + "_meta": { "progressToken": id }, }, }); - let (resp, sid) = post_rpc( - &self.http, - &self.url, - &self.headers, - &body, - self.session_id.as_deref(), - ) - .await?; + let (resp, sid) = post_rpc(self, &body).await?; if let Some(s) = accept_session_id(sid) { self.session_id = Some(s); } @@ -246,6 +277,43 @@ impl McpSession { // server. Ok(assemble_call_result(parsed.content, parsed.is_error)) } + + /// A request carrying everything that places it in this session: the + /// configured headers, the session id, the negotiated protocol version. + fn request(&self, method: reqwest::Method) -> reqwest::RequestBuilder { + let mut req = self.http.request(method, &self.url); + for (k, v) in &self.headers { + req = req.header(k, v); + } + if let Some(s) = &self.session_id { + req = req.header("Mcp-Session-Id", s); + } + if let Some(v) = &self.protocol_version { + req = req.header("MCP-Protocol-Version", v); + } + req + } +} + +/// End the session on the server — the spec's HTTP DELETE — whenever one is +/// dropped: replaced in the pool, aged out, or done with after a Test +/// connection. Without it every session Loach ever opened stayed allocated +/// until the server expired it. Best effort, and only from the runtime: at +/// app exit nothing would be left to send it. +impl Drop for McpSession { + fn drop(&mut self) { + if self.session_id.is_none() { + return; + } + let Ok(rt) = tokio::runtime::Handle::try_current() else { + return; + }; + let req = self.request(reqwest::Method::DELETE).timeout(REQUEST_TIMEOUT); + rt.spawn(async move { + // A server that doesn't support it answers 405, which is fine. + let _ = req.send().await; + }); + } } /// Fold a tool's content array into the result we hand the provider layer: @@ -422,7 +490,7 @@ async fn test_http(server: &McpServer, http: &reqwest::Client) -> Result<McpTest pub(super) fn unwrap_response<T: serde::de::DeserializeOwned>(resp: JsonRpcResponse) -> Result<T> { if let Some(err) = resp.error { - bail!(format_rpc_error(&err)); + return Err(RpcError(err).into()); } let result = resp .result @@ -437,13 +505,10 @@ fn format_rpc_error(err: &JsonRpcError) -> String { /// POST a JSON-RPC frame, returning the decoded response and any session-id /// header the server advertised. async fn post_rpc( - http: &reqwest::Client, - url: &str, - headers: &HashMap<String, String>, + session: &McpSession, body: &Value, - session: Option<&str>, ) -> Result<(JsonRpcResponse, Option<String>)> { - let (raw, sid) = post_rpc_raw(http, url, headers, body, session).await?; + let (raw, sid) = post_rpc_raw(session, body).await?; // `truncate` the body before it lands in the message: `raw` is bounded // only by MAX_RESPONSE_BYTES (4 MiB), and this error string is surfaced // as a ToolResult event that gets persisted into the transcript and @@ -454,35 +519,43 @@ async fn post_rpc( } async fn post_rpc_raw( - http: &reqwest::Client, - url: &str, - headers: &HashMap<String, String>, + session: &McpSession, body: &Value, - session: Option<&str>, ) -> Result<(String, Option<String>)> { - let mut req = http - .post(url) + // A request that asked for progress (`tools/call`) may run past + // REQUEST_TIMEOUT for as long as the server keeps reporting progress + // on it, up to MAX_TOOL_CALL_TIME. reqwest's own timeout can't move, + // so for such a request it holds only that cap, and `deadline` — + // which the SSE reader moves on each report — the rest. + let reports_progress = body.pointer("/params/_meta/progressToken").is_some(); + let started = tokio::time::Instant::now(); + let deadline = started + REQUEST_TIMEOUT; + let req = session + .request(reqwest::Method::POST) .header("Content-Type", "application/json") // Streamable HTTP servers can reply with either JSON or SSE; tell // them we'll accept either so we get the most compatible response. .header("Accept", "application/json, text/event-stream") .json(body) - .timeout(REQUEST_TIMEOUT); - for (k, v) in headers { - req = req.header(k, v); - } - if let Some(s) = session { - req = req.header("Mcp-Session-Id", s); - } + .timeout(if reports_progress { MAX_TOOL_CALL_TIME } else { REQUEST_TIMEOUT }); - let resp = match req.send().await { + let Ok(sent) = tokio::time::timeout_at(deadline, req.send()).await else { + bail!( + "POST to MCP server failed: no response within {}s", + REQUEST_TIMEOUT.as_secs() + ); + }; + let resp = match sent { Ok(r) => r, Err(e) => { // A connect-phase failure means the request never reached the // server, so replaying it can't double-run anything. Anything // else (notably a timeout) may have been delivered and executed. let tag_safe = e.is_connect(); - let err = anyhow::Error::new(e).context("POST to MCP server failed"); + // Without the URL reqwest quotes: this text reaches the model and + // the transcript, and an MCP URL can carry its credential in the + // path or query (`https://mcp.zapier.com/api/mcp/s/<secret>/mcp`). + let err = anyhow::Error::new(e.without_url()).context("POST to MCP server failed"); return Err(if tag_safe { err.context(PreExecutionFailure) } else { @@ -503,15 +576,29 @@ async fn post_rpc_raw( .unwrap_or("") .to_string(); if !status.is_success() { + let location = resp + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .map(str::to_string); let text = read_capped_text(resp) .await .context("read MCP server response")?; - let err = anyhow!("server responded with HTTP {}: {}", status, truncate(&text)); + // Redirects aren't followed — the client is pinned to the address + // it resolved — so a bare "HTTP 307" left the user guessing where + // the server wanted them. Usually it's a missing trailing `/`. + let hint = location + .filter(|_| status.is_redirection()) + .and_then(|l| redirect_hint(&session.url, &l)); + let err = match hint { + Some(hint) => anyhow!("server responded with HTTP {status}: {hint}"), + None => anyhow!("server responded with HTTP {}: {}", status, truncate(&text)), + }; // 404 against a session-carrying request is the spec's "unknown or // expired session" signal: the server refused the envelope, so it // never got as far as running the tool. That's the case the pooled // retry exists for. - return Err(if status == reqwest::StatusCode::NOT_FOUND && session.is_some() { + return Err(if status == reqwest::StatusCode::NOT_FOUND && session.session_id.is_some() { err.context(PreExecutionFailure) } else { err @@ -522,32 +609,46 @@ async fn post_rpc_raw( // stream. Read those incrementally rather than to EOF — see // `read_sse_rpc_response`. let payload = if content_type.contains("text/event-stream") { - read_sse_rpc_response(resp).await? + let id = body.get("id").and_then(Value::as_i64); + let progress_cap = reports_progress.then_some(started + MAX_TOOL_CALL_TIME); + read_sse_rpc_response(session, resp, id, deadline, progress_cap).await? } else { - read_capped_text(resp) - .await - .context("read MCP server response")? + let Ok(text) = tokio::time::timeout_at(deadline, read_capped_text(resp)).await else { + bail!( + "MCP server didn't finish its reply within {}s", + REQUEST_TIMEOUT.as_secs() + ); + }; + text.context("read MCP server response")? }; Ok((payload, sid)) } -/// Pull the JSON-RPC *response* out of an SSE body, returning as soon as it -/// arrives instead of draining the stream first. +/// Pull the JSON-RPC *response* out of an SSE body, returning as soon as +/// it arrives instead of draining the stream first. /// -/// Two things went wrong with read-to-EOF-then-parse. The spec only *SHOULD* -/// close the stream after the response, so a server that legitimately holds -/// it open stalled every call until the 30 s request timeout — the reply had -/// been sitting in our buffer the whole time. And a server may send -/// notifications (progress updates, logging) on the same stream *before* the -/// response, which `extract_first_sse_data` would hand back as if it were -/// the answer, failing with "response missing both result and error". +/// Two things went wrong with read-to-EOF-then-parse. The spec only +/// *SHOULD* close the stream after the response, so a server that +/// legitimately holds it open stalled every call until the 30 s request +/// timeout — the reply had been sitting in our buffer the whole time. And +/// a server may send its own messages on the same stream *before* the +/// response — notifications (progress updates, logging), and requests of +/// its own, a `ping` above all — which `extract_first_sse_data` would hand +/// back as if they were the answer. /// -/// So: parse frames as they complete, skip anything that is a notification -/// (a JSON-RPC message with `method` and no `id`), and return the first -/// frame carrying `result` or `error`. Dropping the response afterwards +/// So: parse frames as they complete, answer the server's requests, let +/// progress on this request (`id`) move `deadline` — up to `progress_cap`, +/// for a request that asked for progress — skip the rest, and return the +/// frame carrying the reply to `id`. Dropping the response afterwards /// closes the connection. -async fn read_sse_rpc_response(resp: reqwest::Response) -> Result<String> { +async fn read_sse_rpc_response( + session: &McpSession, + resp: reqwest::Response, + id: Option<i64>, + mut deadline: tokio::time::Instant, + progress_cap: Option<tokio::time::Instant>, +) -> Result<String> { let mut buf: Vec<u8> = Vec::new(); let mut scanned = 0usize; let mut stream = resp.bytes_stream(); @@ -565,11 +666,18 @@ async fn read_sse_rpc_response(resp: reqwest::Response) -> Result<String> { scanned = 0; if let Some(data) = extract_first_sse_data(&frame) { - if !is_jsonrpc_notification(&data) { - return Ok(data); + match sse_frame(&data, id) { + SseFrame::Reply => return Ok(data), + SseFrame::Answer(reply) => answer(session, &reply), + SseFrame::Progress => { + if let Some(cap) = progress_cap { + deadline = (tokio::time::Instant::now() + REQUEST_TIMEOUT).min(cap); + } + } + // Legal to receive here. Keep reading for the + // response the caller is actually waiting on. + SseFrame::Skip => {} } - // A notification — legal to receive here. Keep reading for - // the response the caller is actually waiting on. } } // No complete frame yet. Resume the next scan a few bytes back from @@ -578,7 +686,13 @@ async fn read_sse_rpc_response(resp: reqwest::Response) -> Result<String> { // restarted past its first byte. scanned = buf.len().saturating_sub(3); - match stream.next().await { + let Ok(next) = tokio::time::timeout_at(deadline, stream.next()).await else { + bail!( + "MCP server didn't reply in time — {}s went by without the reply or progress on it", + REQUEST_TIMEOUT.as_secs() + ); + }; + match next { Some(chunk) => { let chunk = chunk.context("reading MCP server response body")?; if buf.len() + chunk.len() > MAX_RESPONSE_BYTES { @@ -589,25 +703,90 @@ async fn read_sse_rpc_response(resp: reqwest::Response) -> Result<String> { buf.extend_from_slice(&chunk); } // Stream ended. Anything left is a final frame without its - // terminating blank line — accept it if it parses. + // terminating blank line — accept it if it's the reply. None => { let tail = String::from_utf8_lossy(&buf).into_owned(); return extract_first_sse_data(&tail) - .filter(|d| !is_jsonrpc_notification(d)) + .filter(|d| sse_frame(d, id) == SseFrame::Reply) .ok_or_else(|| anyhow!("event-stream response contained no data frame")); } } } } -/// Whether `data` is a JSON-RPC notification rather than the response we're -/// waiting for. Notifications carry `method` and no `id`; responses carry an -/// `id` plus `result` or `error`. Unparseable payloads are treated as -/// non-notifications so the existing error path reports them. -fn is_jsonrpc_notification(data: &str) -> bool { - serde_json::from_str::<serde_json::Value>(data) - .ok() - .is_some_and(|v| v.get("method").is_some() && v.get("id").is_none()) +/// Answer a request the server made of us on a reply's stream. It goes +/// back as a POST of its own, like any client message, and in the +/// background, so the wait for the reply carries on. Best effort. +fn answer(session: &McpSession, reply: &Value) { + let req = session + .request(reqwest::Method::POST) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .json(reply) + .timeout(REQUEST_TIMEOUT); + tokio::spawn(async move { + let _ = req.send().await; + }); +} + +/// What one `data:` payload on a request's SSE stream is to that request. +#[derive(Debug, PartialEq)] +enum SseFrame { + /// Its reply — or something unreadable, which the caller then reports. + Reply, + /// A request the server makes of us first (`ping`); this is the answer. + Answer(Value), + /// Progress on the request. + Progress, + /// Anything else: other notifications, a reply to some other id. + Skip, +} + +/// Sort a payload by what it is to the request with `id`. The first frame +/// that wasn't a notification used to be taken for the reply, so a `ping` +/// the server sent ahead of it failed the call. +fn sse_frame(data: &str, id: Option<i64>) -> SseFrame { + let Ok(msg) = serde_json::from_str::<Value>(data) else { + return SseFrame::Reply; + }; + match classify(&msg) { + Incoming::Response(r) if id.is_none_or(|id| id == r) => SseFrame::Reply, + Incoming::Response(_) => SseFrame::Skip, + Incoming::Request { id: theirs, method } => { + SseFrame::Answer(server_request_reply(&method, theirs)) + } + Incoming::Progress(token) if Some(token) == id => SseFrame::Progress, + Incoming::Progress(_) | Incoming::Notification => SseFrame::Skip, + Incoming::Other => SseFrame::Reply, + } +} + +/// What a 3xx points at, for the error. Said without quoting the target's +/// path or query, which can carry the server's credential just as the +/// configured URL can (see the connect error in `post_rpc_raw`) — except for +/// the usual case, the same URL with or without a trailing `/`, which is +/// spelled out because the fix is the user's to make. +fn redirect_hint(from: &str, location: &str) -> Option<String> { + let from = reqwest::Url::parse(from).ok()?; + let to = from.join(location).ok()?; + let same_origin = from.origin() == to.origin(); + if same_origin + && from.query() == to.query() + && from.path() != to.path() + && from.path().trim_end_matches('/') == to.path().trim_end_matches('/') + { + return Some(if to.path().ends_with('/') { + "it redirects to the same URL with a trailing `/` — add one to the server URL".into() + } else { + "it redirects to the same URL without its trailing `/` — remove it from the server URL" + .into() + }); + } + Some(if same_origin { + "it redirects to another address on the same server".into() + } else { + format!("it redirects to {}", to.origin().ascii_serialization()) + }) } /// Read a reqwest response body as text with a hard `MAX_RESPONSE_BYTES` @@ -637,9 +816,9 @@ fn extract_first_sse_data(s: &str) -> Option<String> { // response across several `data:` lines (legal SSE) handed us a // truncated, unparseable frame and its tools silently never loaded. // Gather every `data:` line of the first event (terminated by a blank - // line) and join them. + // line) and join them. Lines may end in `\r` alone, too. let mut data: Vec<&str> = Vec::new(); - for line in s.lines() { + for line in frame_lines(s) { if let Some(rest) = line.strip_prefix("data:") { // Per spec, a single leading space after the colon is stripped. data.push(rest.strip_prefix(' ').unwrap_or(rest)); @@ -752,6 +931,284 @@ mod tests { assert!(r.attachments.is_empty()); } + /// A transport error goes to the model and the transcript, so it must + /// not quote a URL that carries the server's credential. + #[tokio::test] + async fn a_failed_post_leaves_the_url_out_of_its_error() { + // A server that hangs up without answering. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let port = listener.local_addr().expect("addr").port(); + tokio::spawn(async move { + if let Ok((conn, _)) = listener.accept().await { + drop(conn); + } + }); + let url = format!("http://127.0.0.1:{port}/api/mcp/s/sk-secret-123/mcp"); + let session = McpSession::new(reqwest::Client::new(), &row(&url)).expect("session"); + let err = post_rpc_raw(&session, &json!({})) + .await + .expect_err("the server hung up"); + let shown = format!("{err:#}"); + assert!(shown.contains("POST to MCP server failed"), "{shown}"); + assert!(!shown.contains("sk-secret-123"), "{shown}"); + } + + fn row(url: &str) -> McpServer { + McpServer { + id: "t".into(), + name: "t".into(), + transport: "http".into(), + url: url.into(), + headers_json: None, + command: None, + args_json: None, + env_json: None, + auto_approve: false, + allowed_tools_json: None, + enabled: true, + created_at: 0, + updated_at: 0, + } + } + + /// A whole HTTP/1.1 response, closing the connection after it. + fn http(status: &str, headers: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\n{headers}content-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + } + + /// A local server for the transport tests. Each connection carries one + /// request, answered with `respond(&request)`; every request it saw comes + /// back on the channel, in the order they arrived. + async fn mock_server(respond: fn(&str) -> String) -> (String, tokio::sync::mpsc::UnboundedReceiver<String>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let url = format!("http://127.0.0.1:{}/mcp", listener.local_addr().expect("addr").port()); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + while let Ok((mut conn, _)) = listener.accept().await { + let tx = tx.clone(); + tokio::spawn(async move { + // The head, then as much body as it announces. + let mut raw = Vec::new(); + let mut chunk = [0u8; 4096]; + let request = loop { + let n = match conn.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(n) => n, + }; + raw.extend_from_slice(&chunk[..n]); + let text = String::from_utf8_lossy(&raw).into_owned(); + let Some(head_end) = text.find("\r\n\r\n") else { continue }; + let body_len = text[..head_end] + .lines() + .find_map(|l| { + let (k, v) = l.split_once(':')?; + k.eq_ignore_ascii_case("content-length").then(|| v.trim().parse().ok())? + }) + .unwrap_or(0); + if raw.len() >= head_end + 4 + body_len { + break text; + } + }; + let _ = tx.send(request.clone()); + let _ = conn.write_all(respond(&request).as_bytes()).await; + }); + } + }); + (url, rx) + } + + /// A server may ask something of its own — a `ping` — on the stream + /// ahead of the reply. It used to be taken for the reply; now it is + /// answered with a POST of its own, and the wait goes on to the frame + /// that carries the request's id. + #[tokio::test] + async fn a_ping_ahead_of_the_reply_is_answered_and_passed_over() { + fn respond(request: &str) -> String { + if !request.contains(r#""method":"tools/list""#) { + return http("202 Accepted", "", ""); + } + let frames = concat!( + "data: {\"jsonrpc\":\"2.0\",\"id\":\"srv-1\",\"method\":\"ping\"}\n\n", + "data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\",\"params\":{}}\r\n\r\n", + "data: {\"jsonrpc\":\"2.0\",\"id\":99,\"result\":{}}\n\n", + "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":[{\"name\":\"a\"}]}}\n\n", + ); + http("200 OK", "content-type: text/event-stream\r\n", frames) + } + let (url, mut seen) = mock_server(respond).await; + let mut session = McpSession::new(reqwest::Client::new(), &row(&url)).expect("session"); + let tools = session.list_tools_raw().await.expect("the reply after the ping"); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name, "a"); + + let _list = seen.recv().await; + let pong = tokio::time::timeout(Duration::from_secs(5), seen.recv()) + .await + .expect("the ping was answered") + .expect("a request"); + assert!(pong.contains(r#""id":"srv-1""#) && pong.contains(r#""result":{}"#), "{pong}"); + } + + /// After the handshake every request names the protocol version the + /// server settled on, and a catalogue served in pages is read to its + /// last page. + #[tokio::test] + async fn requests_after_initialize_carry_the_version_and_every_page_is_read() { + fn respond(request: &str) -> String { + let json = "content-type: application/json\r\n"; + if request.contains(r#""method":"initialize""#) { + http("200 OK", json, r#"{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-03-26"}}"#) + } else if request.contains(r#""cursor":"p2""#) { + http("200 OK", json, r#"{"jsonrpc":"2.0","id":3,"result":{"tools":[{"name":"b"}]}}"#) + } else if request.contains(r#""method":"tools/list""#) { + http( + "200 OK", + json, + r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"a"}],"nextCursor":"p2"}}"#, + ) + } else { + http("202 Accepted", "", "") + } + } + let (url, mut seen) = mock_server(respond).await; + let mut session = McpSession::new(reqwest::Client::new(), &row(&url)).expect("session"); + session.initialize().await.expect("handshake"); + let names: Vec<String> = session + .list_tools_raw() + .await + .expect("both pages") + .into_iter() + .map(|t| t.name) + .collect(); + assert_eq!(names, ["a", "b"]); + + let mut requests = Vec::new(); + while let Ok(r) = seen.try_recv() { + requests.push(r.to_ascii_lowercase()); + } + assert_eq!(requests.len(), 4, "initialize, initialized, two pages"); + assert!(!requests[0].contains("mcp-protocol-version"), "{}", requests[0]); + for r in &requests[1..] { + assert!(r.contains("mcp-protocol-version: 2025-03-26"), "{r}"); + } + } + + /// A session the server handed out an id for is ended there — HTTP + /// DELETE with that id — once Loach drops it. + #[tokio::test] + async fn dropping_a_session_ends_it_on_the_server() { + fn respond(request: &str) -> String { + if request.contains(r#""method":"initialize""#) { + http( + "200 OK", + "content-type: application/json\r\nmcp-session-id: sess-42\r\n", + r#"{"jsonrpc":"2.0","id":1,"result":{}}"#, + ) + } else { + http("202 Accepted", "", "") + } + } + let (url, mut seen) = mock_server(respond).await; + let mut session = McpSession::new(reqwest::Client::new(), &row(&url)).expect("session"); + session.initialize().await.expect("handshake"); + drop(session); + let delete = loop { + let r = tokio::time::timeout(Duration::from_secs(5), seen.recv()) + .await + .expect("a DELETE was sent") + .expect("a request"); + if r.starts_with("DELETE") { + break r; + } + }; + assert!(delete.to_ascii_lowercase().contains("mcp-session-id: sess-42"), "{delete}"); + } + + /// The tag rides as context, under more context at times; it must be + /// found either way, or the pool's one safe retry never happens. + #[test] + fn the_pre_execution_tag_is_found_under_any_context() { + let tagged = anyhow!("connection refused") + .context("POST to MCP server failed") + .context(PreExecutionFailure); + assert!(is_pre_execution(&tagged)); + assert!(is_pre_execution(&tagged.context("MCP server `x`"))); + assert!(!is_pre_execution(&anyhow!("timed out").context("POST to MCP server failed"))); + } + + #[test] + fn sse_frames_are_sorted_by_what_they_are_to_the_request() { + let reply = r#"{"jsonrpc":"2.0","id":7,"result":{}}"#; + assert_eq!(sse_frame(reply, Some(7)), SseFrame::Reply); + assert_eq!(sse_frame(reply, Some(8)), SseFrame::Skip, "another request's reply"); + assert_eq!( + sse_frame(r#"{"jsonrpc":"2.0","id":"s1","method":"ping"}"#, Some(7)), + SseFrame::Answer(json!({ "jsonrpc": "2.0", "id": "s1", "result": {} })) + ); + let progress = |token: i64| { + json!({ + "jsonrpc": "2.0", + "method": "notifications/progress", + "params": { "progressToken": token, "progress": 1 }, + }) + .to_string() + }; + assert_eq!(sse_frame(&progress(7), Some(7)), SseFrame::Progress); + assert_eq!(sse_frame(&progress(3), Some(7)), SseFrame::Skip); + assert_eq!( + sse_frame(r#"{"jsonrpc":"2.0","method":"notifications/message","params":{}}"#, Some(7)), + SseFrame::Skip + ); + // Unreadable, or an error without an id: the caller reports it. + assert_eq!(sse_frame("not json", Some(7)), SseFrame::Reply); + assert_eq!( + sse_frame(r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"x"}}"#, Some(7)), + SseFrame::Reply + ); + } + + #[test] + fn extract_first_sse_data_joins_lines_whatever_they_end_in() { + assert_eq!(extract_first_sse_data("event: message\ndata: {\"a\":1}").as_deref(), Some("{\"a\":1}")); + // A payload split over several `data:` lines is joined with `\n`. + assert_eq!(extract_first_sse_data("data: {\"a\":\ndata: 1}").as_deref(), Some("{\"a\":\n1}")); + assert_eq!(extract_first_sse_data("data: {\"a\":\r\ndata:1}\r\n").as_deref(), Some("{\"a\":\n1}")); + assert_eq!(extract_first_sse_data("data: x\rdata: y").as_deref(), Some("x\ny")); + // Only the first event's lines; comments and other fields ignored. + assert_eq!(extract_first_sse_data(": hi\nid: 3\ndata: one\n\ndata: two").as_deref(), Some("one")); + assert_eq!(extract_first_sse_data("event: ping\n: keep-alive"), None); + } + + #[test] + fn truncate_cuts_at_300_chars_on_a_char_boundary() { + assert_eq!(truncate("short"), "short"); + let exact = "é".repeat(300); + assert_eq!(truncate(&exact), exact); + // Two-byte chars put byte 300 mid-char; the cut is by chars. + let out = truncate(&"é".repeat(301)); + assert_eq!(out, format!("{}…", "é".repeat(300))); + } + + /// The fix for the usual redirect is spelled out; any other target is + /// named by its origin only, since its path can hold a credential. + #[test] + fn redirects_are_described_without_their_path() { + let base = "https://mcp.example.com/api/s/sk-1/mcp"; + assert!(redirect_hint(base, "/api/s/sk-1/mcp/").unwrap().contains("add one")); + assert!(redirect_hint("https://h.example/mcp/", "https://h.example/mcp") + .unwrap() + .contains("remove it")); + let elsewhere = redirect_hint(base, "https://login.example.net/s/sk-2?next=1").unwrap(); + assert_eq!(elsewhere, "it redirects to https://login.example.net"); + let same_host = redirect_hint(base, "/v2/s/sk-1/mcp").unwrap(); + assert!(!same_host.contains("sk-1"), "{same_host}"); + assert_eq!(redirect_hint("not a url", "/x"), None); + } + #[test] fn image_content_deserialises_from_mcp_json() { // MCP servers send `type: "image"` with a camelCase `mimeType`. diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 331b522..34f7bb1 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -235,28 +235,36 @@ const MAX_TOOL_DESCRIPTION_CHARS: usize = 2_000; const MAX_TOOL_SCHEMA_BYTES: usize = 16 * 1024; async fn collect_one(server: &McpServer, slug: &str) -> Result<Vec<McpToolDef>> { - let raws = if server.is_stdio() { - // Reuse the pooled process. Spawning one just to list tools and - // another for the first call would pay the cold start twice — an - // `npx`-backed server takes seconds each time — and the pool is - // what keeps the process alive between turns anyway. - let slot = session_slot(&server.id); - let mut pooled = slot.lock().await; + // Reuse the pooled session, whichever the transport. Spawning a stdio + // process just to list tools and another for the first call would pay + // the cold start twice — an `npx`-backed server takes seconds each time — + // and the pool is what keeps the process alive between turns anyway. An + // HTTP server was handshaken afresh on every send, leaving a session + // behind on the server each time. + let slot = session_slot(&server.id); + let mut pooled = slot.lock().await; + let mut retried = false; + let raws = loop { + let reused = pooled + .as_ref() + .is_some_and(|p| p.fingerprint == session_fingerprint(server) && p.is_fresh()); let session = ensure_pooled(&mut pooled, server).await?; match session.list_tools_raw().await { - Ok(r) => r, + Ok(r) => break r, Err(e) => { if !pooled.as_ref().is_some_and(|p| keeps_session(p, &e)) { *pooled = None; + // Listing changes nothing, so a reused session that has + // gone bad — expired on the server, most often — gets + // one more try on a fresh one. + if reused && !retried { + retried = true; + continue; + } } return Err(e); } } - } else { - let (http, _) = pin_client_for(server).await?; - let mut session = McpSession::new(http, server)?; - session.initialize().await?; - session.list_tools_raw().await? }; // Hoist the per-server clones out of the loop. The strings end up // copied into every `McpToolDef` anyway, but doing it once and reusing @@ -307,7 +315,7 @@ async fn collect_one(server: &McpServer, slug: &str) -> Result<Vec<McpToolDef>> ); continue; } - let qualified = format!("{slug}__{safe}"); + let qualified = qualify(slug, &safe); // Two raw names on the same server can sanitise to the same string // (`repo.search` and `repo/search` both collapse to `repo_search`). // The qualified-name dedup at the slug level only handles cross- @@ -432,7 +440,8 @@ pub async fn dispatch_tool_call( // and keeping a session it rejects makes every later call // fail identically until the TTL ages it out. // - // Except a stdio server that is merely slow: dropping its + // Except a server that answered with a JSON-RPC error, + // and a stdio server that is merely slow: dropping its // session stops the process, and with it whatever the // call was still doing — over one late reply. if !keeps_session(p, &e) { @@ -465,15 +474,22 @@ pub async fn dispatch_tool_call( out } -/// Whether a pooled session survives `err` from a call on it: only a stdio -/// server that is still running and just didn't answer in time, or answered -/// with more than Loach reads in one go. Stopping it would stop whatever it -/// was still doing — a long download, a sign-in page it opened — over one -/// reply; a late one, when it comes, is ignored. +/// Whether a pooled session survives `err` from a call on it. A server that +/// answered with a JSON-RPC error is up and talking: bad arguments or an +/// unknown tool say nothing against the session, and dropping it stopped a +/// healthy stdio server (and sent HTTP back through the handshake) over a +/// routine refusal. +/// Beyond that, only a stdio server that is still running and just didn't +/// answer in time, or answered with more than — or other than — Loach can +/// read. Stopping it would stop whatever it was still doing — a long +/// download, a sign-in page it opened — over one reply; a late one, when it +/// comes, is ignored. A second miss in a row makes it hung, and no longer +/// fresh (see `StdioSession::is_hung`). fn keeps_session(p: &PooledSession, err: &anyhow::Error) -> bool { - matches!(p.session, Session::Stdio(_)) - && p.is_fresh() - && (stdio::is_reply_timeout(err) || stdio::is_reply_too_long(err)) + p.is_fresh() + && (client::is_rpc_error(err) + || (matches!(p.session, Session::Stdio(_)) + && (stdio::is_reply_timeout(err) || stdio::is_unreadable_reply(err)))) } /// Whether a call to `tool_name` on `server_id` must be confirmed by the @@ -640,11 +656,11 @@ struct PooledSession { impl PooledSession { /// HTTP sessions age out after [`SESSION_TTL`] (servers expire idle /// sessions on their own schedule). A stdio process is the session — - /// it stays good for as long as it is running. + /// it stays good for as long as it is running and answering. fn is_fresh(&self) -> bool { match &self.session { Session::Http(_) => self.created_at.elapsed() < SESSION_TTL, - Session::Stdio(s) => s.is_alive(), + Session::Stdio(s) => s.is_alive() && !s.is_hung(), } } } @@ -716,6 +732,25 @@ fn sanitise_tool_name(name: &str) -> String { out } +/// Longest function name OpenAI accepts. One tool over it fails every +/// request that offers tools, not just calls to that one. +const MAX_QUALIFIED_NAME_LEN: usize = 64; + +/// `slug__tool`, cut to [`MAX_QUALIFIED_NAME_LEN`] when the pair runs over +/// it. A cut name ends in a hash of the whole one, so two long names that +/// start alike stay apart. It only has to be unique: a call is routed by +/// looking the name up (`providers::resolve_qualified`), never by taking it +/// apart. +fn qualify(slug: &str, tool: &str) -> String { + let full = format!("{slug}__{tool}"); + if full.len() <= MAX_QUALIFIED_NAME_LEN { + return full; + } + let hash = stable_id_suffix(&full); + // Slugs and sanitised tool names are ASCII, so any cut is a char boundary. + format!("{}_{hash}", &full[..MAX_QUALIFIED_NAME_LEN - hash.len() - 1]) +} + /// Build per-server slugs, disambiguating collisions by appending a /// short slice of the server id. The first server to land on a given /// slug keeps it bare; subsequent collisions get `_<6-char-id>` suffixed @@ -771,8 +806,9 @@ fn build_unique_slugs(servers: &[McpServer]) -> Vec<String> { /// 6-character base-36 hash of a server id. Stable across runs (same /// input always yields the same suffix) so a qualified tool name persists /// even if the DB row order shifts or another server is removed from the -/// configured list. Only used as a defensive fallback when the id itself -/// contains no usable alphanumeric characters. +/// configured list. For ids, only used as a defensive fallback when the id +/// itself contains no usable alphanumeric characters; [`qualify`] also +/// hashes an over-long qualified name with it. fn stable_id_suffix(id: &str) -> String { // FNV-1a 64-bit. Cheap, no deps, plenty of bits for a 6-char base-36 // window — collisions in that window only matter inside a single @@ -961,7 +997,7 @@ mod tests { created_at: Instant::now(), session: Session::Stdio(StdioSession::spawn(&row).await.expect("spawn")), }; - let too_long = anyhow::Error::new(stdio::ReplyTooLong) + let too_long = anyhow::Error::new(stdio::UnreadableReply) .context("MCP server `big`: its reply was over 4 MiB"); let late = anyhow::Error::new(stdio::ReplyTimeout) .context("MCP server `big` did not reply to `tools/call` within 30s"); @@ -973,6 +1009,115 @@ mod tests { )); } + /// A JSON-RPC error reply comes from a server that is up and answering, + /// so neither transport loses its session over one; a transport failure + /// or an HTTP status still does. + #[tokio::test] + async fn a_json_rpc_error_reply_keeps_the_session() { + let resp: types::JsonRpcResponse = serde_json::from_value(serde_json::json!({ + "jsonrpc": "2.0", + "id": 3, + "error": { "code": -32602, "message": "Invalid params" }, + })) + .expect("parses"); + let refused = client::unwrap_response::<Value>(resp) + .expect_err("an error reply") + .context("MCP server `x`"); + assert!(format!("{refused:#}").contains("server returned error -32602: Invalid params")); + + let row = stdio_row("x", "set /p _=", "read _"); + let stdio = PooledSession { + fingerprint: session_fingerprint(&row), + created_at: Instant::now(), + session: Session::Stdio(StdioSession::spawn(&row).await.expect("spawn")), + }; + let web = http_row("x"); + let http = PooledSession { + fingerprint: session_fingerprint(&web), + created_at: Instant::now(), + session: Session::Http(McpSession::new(reqwest::Client::new(), &web).expect("session")), + }; + for pooled in [&stdio, &http] { + assert!(keeps_session(pooled, &refused)); + assert!(!keeps_session(pooled, &anyhow!("POST to MCP server failed"))); + } + assert!(!keeps_session(&http, &anyhow!("server responded with HTTP 400: not initialized"))); + } + + /// Server names are free-form; the slug keeps only what a function name + /// may hold, turns whitespace into `_`, and never comes out empty. + #[test] + fn slug_for_keeps_only_function_name_characters() { + assert_eq!(slug_for("GitHub"), "GitHub"); + assert_eq!(slug_for("ACME — production"), "ACME__production"); + assert_eq!(slug_for("my-server_2"), "my-server_2"); + assert_eq!(slug_for("Zürich files!"), "Zrich_files"); + assert_eq!(slug_for("💼"), "srv"); + assert_eq!(slug_for(""), "srv"); + } + + /// MCP's own separators become `_` so namespaced names stay readable; + /// anything else outside the pattern is dropped, down to nothing. + #[test] + fn sanitise_tool_name_maps_separators_and_drops_the_rest() { + assert_eq!(sanitise_tool_name("search"), "search"); + assert_eq!(sanitise_tool_name("repo.search"), "repo_search"); + assert_eq!(sanitise_tool_name("notebook/list"), "notebook_list"); + assert_eq!(sanitise_tool_name("ns:get item"), "ns_get_item"); + assert_eq!(sanitise_tool_name("ask?(now)"), "asknow"); + assert_eq!(sanitise_tool_name("🔧"), "", "the caller drops the tool"); + } + + /// Two servers whose names slug alike get distinct prefixes, decided by + /// their ids — not their order — so a qualified name survives a restart. + #[test] + fn colliding_slugs_are_told_apart_by_server_id() { + let server = |id: &str, name: &str| McpServer { + id: id.into(), + ..http_row(name) + }; + let slugs = build_unique_slugs(&[ + server("abc123def", "GitHub"), + server("xyz789uvw", "GitHub!"), + server("other", "Linear"), + ]); + assert_eq!(slugs, ["GitHub", "GitHub_xyz789", "Linear"]); + + // Same first six id characters: the full-id hash separates them. + let slugs = build_unique_slugs(&[ + server("aaaaaa-1", "fs"), + server("aaaaaa-2", "fs"), + server("aaaaaa-3", "fs"), + ]); + assert_eq!(slugs[0], "fs"); + assert_eq!(slugs[1], "fs_aaaaaa"); + assert_eq!(slugs[2], format!("fs_{}", stable_id_suffix("aaaaaa-3"))); + + // An id with no usable characters still gets a stable suffix. + let slugs = build_unique_slugs(&[server("x", "db"), server("---", "db")]); + assert_eq!(slugs[1], format!("db_{}", stable_id_suffix("---"))); + assert_eq!(slugs, build_unique_slugs(&[server("x", "db"), server("---", "db")])); + } + + /// OpenAI refuses a function name over 64 characters, so a long server + /// plus tool name is cut — and stays unique and the same across runs. + #[test] + fn long_qualified_names_are_cut_to_the_limit_and_kept_apart() { + assert_eq!(qualify("github", "search"), "github__search"); + let at_limit = "t".repeat(MAX_QUALIFIED_NAME_LEN - "srv__".len()); + assert_eq!(qualify("srv", &at_limit), format!("srv__{at_limit}")); + + let slug = "acme_production_internal_tools_gateway"; + let a = qualify(slug, "list_pull_request_review_comments_for_repository"); + let b = qualify(slug, "list_pull_request_review_comments_for_organization"); + assert_eq!(a.len(), MAX_QUALIFIED_NAME_LEN); + assert_eq!(b.len(), MAX_QUALIFIED_NAME_LEN); + assert_ne!(a, b, "names alike up to the cut stay apart"); + assert_eq!(a, qualify(slug, "list_pull_request_review_comments_for_repository")); + assert!(a.starts_with("acme_production_internal_tools_gateway__list_pull")); + assert!(a.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'), "{a}"); + } + /// A stdio server that dies while starting leaves its stderr — which can /// print anything, a token included — out of the errors the chat notice /// and `/tools` show. Test connection still shows it. diff --git a/src-tauri/src/mcp/stdio.rs b/src-tauri/src/mcp/stdio.rs index b0a9251..9bf9306 100644 --- a/src-tauri/src/mcp/stdio.rs +++ b/src-tauri/src/mcp/stdio.rs @@ -37,7 +37,7 @@ use tokio::sync::oneshot; use super::client::{assemble_call_result, unwrap_response, PreExecutionFailure}; use super::types::{ CallToolResult, InitializeResult, JsonRpcResponse, ListToolsResult, McpCallResult, - McpTestResult, McpTool, McpToolRaw, PROTOCOL_VERSION, + McpTestResult, McpTool, McpToolRaw, MAX_TOOL_CALL_TIME, MAX_TOOL_PAGES, PROTOCOL_VERSION, }; use crate::db::McpServer; @@ -54,6 +54,12 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// killed with everything it started. const SHUTDOWN_GRACE: Duration = Duration::from_secs(2); +/// The same moment at app exit, shared by every server rather than given to +/// each: the app waits on it, so it is kept short, and it ends as soon as +/// the last of them has exited — which a server reading its stdin does +/// almost at once. +const EXIT_GRACE: Duration = Duration::from_secs(1); + /// Marks a request the server didn't answer within its timeout. It may /// still be working on it — so unlike a dead process, this is no reason /// to stop the server (see `mcp::dispatch_tool_call`). @@ -73,25 +79,32 @@ pub fn is_reply_timeout(err: &anyhow::Error) -> bool { err.chain().any(|c| c.is::<ReplyTimeout>()) } -/// Marks a reply too big to take in (over [`MAX_LINE_BYTES`]). The server -/// did answer and is still running, so this fails the call only — no -/// reason to stop the server either. +/// Marks a reply Loach couldn't take in: too big (over [`MAX_LINE_BYTES`]) +/// or not a JSON-RPC response it can read. The server did answer and is +/// still running, so this fails the call only — no reason to stop the +/// server either. #[derive(Debug)] -pub struct ReplyTooLong; +pub struct UnreadableReply; -impl std::fmt::Display for ReplyTooLong { +impl std::fmt::Display for UnreadableReply { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("the server is still running") } } -impl std::error::Error for ReplyTooLong {} +impl std::error::Error for UnreadableReply {} -/// True when `err` is a [`ReplyTooLong`]. -pub fn is_reply_too_long(err: &anyhow::Error) -> bool { - err.chain().any(|c| c.is::<ReplyTooLong>()) +/// True when `err` is an [`UnreadableReply`]. +pub fn is_unreadable_reply(err: &anyhow::Error) -> bool { + err.chain().any(|c| c.is::<UnreadableReply>()) } +/// Requests in a row a server may leave unanswered before it counts as +/// hung. One late reply is a slow tool, and stopping the server would stop +/// whatever it was still doing; with nothing answered across two, its event +/// loop is likely blocked, and every later call would sit out the timeout. +const HUNG_AFTER_MISSED_REPLIES: u32 = 2; + /// Starts the stderr tail [`StdioSession::stderr_suffix`] appends to an /// error message. const STDERR_MARK: &str = " — stderr: "; @@ -120,10 +133,22 @@ const STDERR_TAIL_BYTES: usize = 8 * 1024; const CLIENT_NAME: &str = "loach"; const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); -/// Requests waiting for their reply. The reader delivers either the reply -/// or why Loach couldn't read one — a reply too big to take in. -type Pending = Arc<Mutex<HashMap<i64, oneshot::Sender<Result<JsonRpcResponse, String>>>>>; +/// Requests waiting for their reply. +type Pending = Arc<Mutex<HashMap<i64, Waiter>>>; type StderrTail = Arc<Mutex<Vec<u8>>>; +/// The server's stdin, shared with the reader (which answers its pings). +/// `None` once closed for shutdown — taking it closes the pipe for every +/// holder at once, which dropping one handle of several doesn't. +type SharedStdin = Arc<tokio::sync::Mutex<Option<ChildStdin>>>; + +/// One request's side of the reader. The reader delivers either the reply +/// or why Loach couldn't read one — a reply too big to take in, or one that +/// isn't a valid response — and pokes `progress` when the server reports +/// progress on the request. +struct Waiter { + reply: oneshot::Sender<Result<JsonRpcResponse, String>>, + progress: Arc<tokio::sync::Notify>, +} /// A live stdio MCP server: the child plus the reader task that pairs its /// replies to our outstanding requests. @@ -131,11 +156,18 @@ pub struct StdioSession { /// Taken by `Drop` and handed to [`shut_down`]; `Some` for as long as /// the session exists. process: Option<Process>, - stdin: Arc<tokio::sync::Mutex<ChildStdin>>, + stdin: SharedStdin, pending: Pending, alive: Arc<AtomicBool>, stderr_tail: StderrTail, next_id: i64, + /// Requests timed out since the last one the server answered. + missed_replies: u32, + /// Set while a request is being written, and left set when the write + /// never finished — timed out, or dropped by a Stop. Part of a line is + /// then in the pipe, and the server would read it as the start of the + /// next message, so the session is done for. + torn_write: bool, /// For error messages. label: String, } @@ -160,17 +192,29 @@ impl Drop for StdioSession { Ok(rt) => { rt.spawn(shut_down(process, self.stdin.clone())); } - // Off the runtime — app exit, from `RunEvent::Exit` — nothing - // would be left to wait out a graceful exit, so stop it now: - // dropping `child` (kill_on_drop) and `tree` does that. + // Off the runtime — app exit, from `RunEvent::Exit` — no task + // would be left to wait out a graceful exit. Close stdin now and + // leave the waiting to `kill_all_now`, which the exit runs next: + // every server closed this way gets one shared moment to exit + // before what's left is stopped. Stdin held mid-write can't be + // closed, and a server that isn't told to go won't — stop that + // one now: dropping `child` (kill_on_drop) and `tree` does that. Err(_) => { process.reader.abort(); process.stderr_pump.abort(); + if let Ok(mut stdin) = self.stdin.try_lock() { + stdin.take(); + EXITING.lock().expect("exiting list poisoned").push(process); + } } } } } +/// Servers whose stdin was closed as the app exits, waiting for +/// [`kill_all_now`] to give them their moment. +static EXITING: Mutex<Vec<Process>> = Mutex::new(Vec::new()); + /// Stop a server the way the MCP spec asks for stdio: close its stdin, /// give it [`SHUTDOWN_GRACE`] to exit, and only then kill it. /// @@ -179,7 +223,7 @@ impl Drop for StdioSession { /// browser to sign in, and a browser that wasn't running yet starts as its /// descendant. One that doesn't exit in time is killed together with /// everything it started. -async fn shut_down(process: Process, stdin: Arc<tokio::sync::Mutex<ChildStdin>>) { +async fn shut_down(process: Process, stdin: SharedStdin) { let Process { mut child, tree, @@ -352,7 +396,31 @@ impl Drop for ProcessTree { /// the pool reaches only the sessions no call is holding; one that is /// mid-call would be cleared by a task that never runs once the app is /// gone, and its server would outlive Loach. +/// +/// The ones the pool did clear had their stdin closed as they went (see +/// `Drop for StdioSession`), the spec's way of asking a stdio server to +/// exit. They get [`EXIT_GRACE`] between them to do so; one that has is +/// taken at its word like in [`shut_down`], and the rest are killed. pub fn kill_all_now() { + let mut left: Vec<Process> = std::mem::take(&mut *EXITING.lock().expect("exiting list poisoned")); + let deadline = std::time::Instant::now() + EXIT_GRACE; + loop { + let mut running = Vec::new(); + for mut p in left { + match p.child.try_wait() { + Ok(Some(_)) => p.tree.release(), + _ => running.push(p), + } + } + left = running; + if left.is_empty() || std::time::Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + // `child` (kill_on_drop) and `tree` stop the ones still running. + drop(left); + #[cfg(unix)] for pgid in LIVE_GROUPS.lock().expect("live groups poisoned").drain(..) { // SAFETY: only sends a signal to a group we started. @@ -419,7 +487,7 @@ impl StdioSession { let stdout = child.stdout.take().context("child stdout not piped")?; let stderr = child.stderr.take().context("child stderr not piped")?; - let stdin = Arc::new(tokio::sync::Mutex::new(stdin)); + let stdin: SharedStdin = Arc::new(tokio::sync::Mutex::new(Some(stdin))); let pending: Pending = Arc::new(Mutex::new(HashMap::new())); let alive = Arc::new(AtomicBool::new(true)); let stderr_tail: StderrTail = Arc::new(Mutex::new(Vec::new())); @@ -445,6 +513,8 @@ impl StdioSession { alive, stderr_tail, next_id: 1, + missed_replies: 0, + torn_write: false, label: server.name.clone(), }) } @@ -455,6 +525,14 @@ impl StdioSession { self.alive.load(Ordering::Acquire) } + /// `true` once [`HUNG_AFTER_MISSED_REPLIES`] requests in a row went + /// unanswered, or a request was left half-written on its stdin. The pool + /// treats a hung session like a dead one, so the next call starts the + /// server afresh. + pub fn is_hung(&self) -> bool { + self.missed_replies >= HUNG_AFTER_MISSED_REPLIES || self.torn_write + } + fn fresh_id(&mut self) -> i64 { let id = self.next_id; self.next_id += 1; @@ -476,9 +554,27 @@ impl StdioSession { } pub async fn list_tools_raw(&mut self) -> Result<Vec<McpToolRaw>> { - let resp = self.request("tools/list", None, REQUEST_TIMEOUT).await?; - let parsed: ListToolsResult = unwrap_response(resp)?; - Ok(parsed.tools) + let mut tools = Vec::new(); + let mut cursor: Option<String> = None; + for page in 1.. { + let params = cursor.take().map(|c| json!({ "cursor": c })); + let resp = self.request("tools/list", params, REQUEST_TIMEOUT).await?; + let parsed: ListToolsResult = unwrap_response(resp)?; + tools.extend(parsed.tools); + match parsed.next_cursor.filter(|c| !c.is_empty()) { + None => break, + Some(_) if page >= MAX_TOOL_PAGES => { + tracing::warn!( + "MCP stdio `{}`: tools/list still paging after {MAX_TOOL_PAGES} pages — \ + keeping what's in", + self.label + ); + break; + } + next => cursor = next, + } + } + Ok(tools) } pub async fn call_tool(&mut self, name: &str, arguments: &Value) -> Result<McpCallResult> { @@ -513,11 +609,22 @@ impl StdioSession { if let Some(p) = params { frame["params"] = p; } - let (tx, rx) = oneshot::channel(); - self.pending - .lock() - .expect("stdio pending map poisoned") - .insert(id, tx); + // A tool call asks for progress, under its own id as the token: a + // long tool that reports it keeps its deadline moving (see the wait + // below), where it used to be cut off at `timeout` regardless. + let reports_progress = method == "tools/call" && frame["params"].is_object(); + if reports_progress { + frame["params"]["_meta"] = json!({ "progressToken": id }); + } + let (tx, mut rx) = oneshot::channel(); + let progress = Arc::new(tokio::sync::Notify::new()); + self.pending.lock().expect("stdio pending map poisoned").insert( + id, + Waiter { + reply: tx, + progress: progress.clone(), + }, + ); let mut in_flight = InFlight { pending: self.pending.clone(), stdin: self.stdin.clone(), @@ -527,32 +634,63 @@ impl StdioSession { cancellable: method != "initialize", }; - if let Err(e) = self.write_frame(&frame).await { - let suffix = self.stderr_suffix(); - return Err(e - .context(format!( - "couldn't write to MCP server `{}`{suffix}", - self.label - )) + // Under the request's own timeout: a server that has stopped reading + // its stdin fills the pipe, and the write waited on it for good — + // holding the pool's slot for this server all the while. A write + // that doesn't finish, cut off here or dropped by a Stop, leaves + // `torn_write` set. + self.torn_write = true; + match tokio::time::timeout(timeout, self.write_frame(&frame)).await { + Ok(Ok(())) => self.torn_write = false, + Ok(Err(e)) => { + let suffix = self.stderr_suffix(); + return Err(e + .context(format!( + "couldn't write to MCP server `{}`{suffix}", + self.label + )) + .context(PreExecutionFailure)); + } + // Never fully sent, so never run. + Err(_) => { + return Err(anyhow!( + "MCP server `{}` isn't reading its input: `{method}` couldn't be sent within {}s", + self.label, + timeout.as_secs() + ) .context(PreExecutionFailure)); + } } in_flight.unanswered = true; - match tokio::time::timeout(timeout, rx).await { - Ok(Ok(Ok(resp))) => { + let started = tokio::time::Instant::now(); + let mut deadline = started + timeout; + let reply = loop { + tokio::select! { + r = &mut rx => break Some(r), + _ = progress.notified(), if reports_progress => { + deadline = (tokio::time::Instant::now() + timeout).min(started + MAX_TOOL_CALL_TIME); + } + _ = tokio::time::sleep_until(deadline) => break None, + } + }; + match reply { + Some(Ok(Ok(resp))) => { in_flight.unanswered = false; + self.missed_replies = 0; Ok(resp) } // The server did answer, and is still running: Loach just // couldn't take the answer in. - Ok(Ok(Err(why))) => { + Some(Ok(Err(why))) => { in_flight.unanswered = false; - Err(anyhow::Error::new(ReplyTooLong) + self.missed_replies = 0; + Err(anyhow::Error::new(UnreadableReply) .context(format!("MCP server `{}`: {why}", self.label))) } // Sender dropped: the reader task cleared the map because the // process went away — nobody left to tell. - Ok(Err(_)) => { + Some(Err(_)) => { in_flight.unanswered = false; let suffix = self.stderr_suffix(); bail!( @@ -561,11 +699,12 @@ impl StdioSession { ) } // `in_flight` tells the server to stop as it drops. - Err(_) => { + None => { + self.missed_replies += 1; Err(anyhow::Error::new(ReplyTimeout).context(format!( "MCP server `{}` did not reply to `{method}` within {}s", self.label, - timeout.as_secs() + (deadline - started).as_secs() ))) } } @@ -583,9 +722,7 @@ impl StdioSession { // strings), which is what makes one-message-per-line framing safe. let mut line = serde_json::to_string(frame)?; line.push('\n'); - let mut stdin = self.stdin.lock().await; - stdin.write_all(line.as_bytes()).await?; - stdin.flush().await?; + write_line(&self.stdin, line.as_bytes()).await?; Ok(()) } @@ -628,12 +765,23 @@ impl StdioSession { /// longer wants the result. struct InFlight { pending: Pending, - stdin: Arc<tokio::sync::Mutex<ChildStdin>>, + stdin: SharedStdin, id: i64, unanswered: bool, cancellable: bool, } +/// Write one message line to the server. Fails once its stdin has been +/// closed for shutdown. +async fn write_line(stdin: &SharedStdin, line: &[u8]) -> std::io::Result<()> { + let mut stdin = stdin.lock().await; + let pipe = stdin + .as_mut() + .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::BrokenPipe))?; + pipe.write_all(line).await?; + pipe.flush().await +} + impl Drop for InFlight { fn drop(&mut self) { self.pending @@ -654,9 +802,7 @@ impl Drop for InFlight { let stdin = self.stdin.clone(); rt.spawn(async move { let line = format!("{frame}\n"); - let mut w = stdin.lock().await; - let _ = w.write_all(line.as_bytes()).await; - let _ = w.flush().await; + let _ = write_line(&stdin, line.as_bytes()).await; }); } } @@ -668,7 +814,7 @@ async fn pump_stdout( stdout: tokio::process::ChildStdout, pending: Pending, alive: Arc<AtomicBool>, - stdin: Arc<tokio::sync::Mutex<ChildStdin>>, + stdin: SharedStdin, label: String, ) { let mut reader = BufReader::new(stdout); @@ -689,8 +835,8 @@ async fn pump_stdout( .remove(&id) }); match waiter { - Some(tx) => { - let _ = tx.send(Err(format!( + Some(w) => { + let _ = w.reply.send(Err(format!( "its reply was over {} MiB, more than Loach reads in one go — \ ask the tool for less at a time", MAX_LINE_BYTES / (1024 * 1024) @@ -729,11 +875,16 @@ async fn pump_stdout( .expect("stdio pending map poisoned") .remove(&id); match (waiter, serde_json::from_value::<JsonRpcResponse>(msg)) { - (Some(tx), Ok(resp)) => { - let _ = tx.send(Ok(resp)); + (Some(w), Ok(resp)) => { + let _ = w.reply.send(Ok(resp)); } - (Some(_), Err(e)) => { + // Dropping the sender here read as the server having + // exited, and got a running server stopped. + (Some(w), Err(e)) => { tracing::warn!("MCP stdio `{label}`: malformed response for id {id}: {e}"); + let _ = w.reply.send(Err(format!( + "its reply wasn't a valid JSON-RPC response ({e})" + ))); } // A reply to a request that already timed out. (None, _) => {} @@ -743,11 +894,14 @@ async fn pump_stdout( let reply = server_request_reply(&method, id); if let Ok(mut line) = serde_json::to_string(&reply) { line.push('\n'); - let mut w = stdin.lock().await; - if let Err(e) = w.write_all(line.as_bytes()).await { + if let Err(e) = write_line(&stdin, line.as_bytes()).await { tracing::debug!("MCP stdio `{label}`: couldn't answer `{method}`: {e}"); } - let _ = w.flush().await; + } + } + Incoming::Progress(token) => { + if let Some(w) = pending.lock().expect("stdio pending map poisoned").get(&token) { + w.progress.notify_one(); } } Incoming::Notification => {} @@ -782,19 +936,23 @@ async fn pump_stderr(stderr: tokio::process::ChildStderr, tail: StderrTail) { } } -/// What kind of JSON-RPC frame the server just sent. +/// What kind of JSON-RPC frame the server just sent. Shared with the HTTP +/// transport, which meets the same kinds on a request's SSE stream. #[derive(Debug, PartialEq, Eq)] -enum Incoming { +pub(super) enum Incoming { /// A reply to one of our requests (numeric id we issued). Response(i64), /// The server is asking *us* something (`ping`, `roots/list`, …). Request { id: Value, method: String }, - /// `method` without `id` — progress / logging; nothing to pair. + /// `notifications/progress` on the request whose progress token — the + /// request's own id — this is. + Progress(i64), + /// `method` without `id` — other progress / logging; nothing to pair. Notification, Other, } -fn classify(msg: &Value) -> Incoming { +pub(super) fn classify(msg: &Value) -> Incoming { let has_id = msg.get("id").is_some_and(|v| !v.is_null()); let method = msg.get("method").and_then(Value::as_str); let is_reply = msg.get("result").is_some() || msg.get("error").is_some(); @@ -807,6 +965,12 @@ fn classify(msg: &Value) -> Incoming { id: msg["id"].clone(), method: m.to_string(), }, + (false, Some("notifications/progress"), _) => { + match msg.pointer("/params/progressToken").and_then(Value::as_i64) { + Some(token) => Incoming::Progress(token), + None => Incoming::Notification, + } + } (false, Some(_), _) => Incoming::Notification, _ => Incoming::Other, } @@ -816,7 +980,7 @@ fn classify(msg: &Value) -> Incoming { /// the honest reply to anything beyond a `ping` (and the roots query some /// SDKs issue unprompted) is "method not found" — the server then carries /// on without it rather than hanging on an unanswered id. -fn server_request_reply(method: &str, id: Value) -> Value { +pub(super) fn server_request_reply(method: &str, id: Value) -> Value { match method { "ping" => json!({ "jsonrpc": "2.0", "id": id, "result": {} }), "roots/list" => json!({ "jsonrpc": "2.0", "id": id, "result": { "roots": [] } }), @@ -1068,6 +1232,14 @@ mod tests { classify(&json!({ "jsonrpc": "2.0", "method": "notifications/progress", "params": {} })), Incoming::Notification ); + assert_eq!( + classify(&json!({ + "jsonrpc": "2.0", + "method": "notifications/progress", + "params": { "progressToken": 4, "progress": 1 }, + })), + Incoming::Progress(4) + ); // A response whose id isn't one of our integers can't be paired. assert_eq!( classify(&json!({ "jsonrpc": "2.0", "id": "abc", "result": {} })), @@ -1205,6 +1377,173 @@ mod tests { assert!(marker.exists(), "what the server left running was killed"); } + /// One missed reply is a slow server; two in a row is a hung one, which + /// the pool then starts afresh instead of waiting out every call on it. + #[tokio::test] + async fn a_server_that_misses_two_replies_in_a_row_is_hung() { + // Runs, reads nothing, says nothing. + #[cfg(windows)] + let (command, args) = ( + "cmd".to_string(), + vec!["/d".into(), "/c".into(), "ping -n 30 127.0.0.1 >nul".into()], + ); + #[cfg(unix)] + let (command, args) = ("sh".to_string(), vec!["-c".into(), "sleep 30".into()]); + + let mut session = StdioSession::spawn(&script_server(command, args)).await.expect("spawn"); + let quick = Duration::from_millis(300); + let e = session.request("tools/list", None, quick).await.expect_err("no reply"); + assert!(is_reply_timeout(&e), "{e:#}"); + assert!(!session.is_hung(), "one late reply is no reason to restart it"); + session.request("tools/list", None, quick).await.expect_err("no reply"); + assert!(session.is_hung()); + assert!(session.is_alive(), "hung, not exited"); + } + + /// A server that stops reading its stdin can't hold a request — and with + /// it the pool's slot for the server — forever: the write gives up at the + /// request's timeout, the request counts as never sent, and the line left + /// half-written in the pipe ends the session. + #[tokio::test] + async fn a_request_the_server_never_reads_times_out_and_ends_the_session() { + // Runs, reads nothing. + #[cfg(windows)] + let (command, args) = ( + "cmd".to_string(), + vec!["/d".into(), "/c".into(), "ping -n 30 127.0.0.1 >nul".into()], + ); + #[cfg(unix)] + let (command, args) = ("sh".to_string(), vec!["-c".into(), "sleep 30".into()]); + + let mut session = StdioSession::spawn(&script_server(command, args)).await.expect("spawn"); + // Far more than a pipe holds. + let pad = "x".repeat(3 * 1024 * 1024); + let e = session + .request("tools/list", Some(json!({ "pad": pad })), Duration::from_millis(500)) + .await + .expect_err("nobody reads it"); + assert!(super::super::client::is_pre_execution(&e), "{e:#}"); + assert!(format!("{e:#}").contains("isn't reading its input"), "{e:#}"); + assert!(session.is_hung(), "a half-written line ends the session"); + } + + /// A tool call that reports progress outlives its timeout for as long as + /// the reports keep coming. The server answers after ~3 s, reporting + /// progress about once a second; with a 1.8 s timeout the call only + /// succeeds if each report moves the deadline. + #[tokio::test] + async fn progress_on_a_tool_call_keeps_it_from_timing_out() { + let progress = r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":1,"progress":1}}"#; + let reply = r#"{"jsonrpc":"2.0","id":1,"result":{"content":[]}}"#; + #[cfg(windows)] + let dir = tempfile::TempDir::new().unwrap(); + #[cfg(windows)] + let (command, args) = { + let tick = format!("@echo {progress}\r\n@ping -n 2 127.0.0.1 >nul\r\n"); + let script = dir.path().join("server.cmd"); + std::fs::write( + &script, + format!("@set /p _=\r\n{tick}{tick}{tick}@echo {reply}\r\n@ping -n 10 127.0.0.1 >nul\r\n"), + ) + .unwrap(); + (script.display().to_string(), Vec::<String>::new()) + }; + #[cfg(unix)] + let (command, args) = ( + "sh".to_string(), + vec![ + "-c".to_string(), + format!( + "read _; for i in 1 2 3; do printf '%s\\n' '{progress}'; sleep 1; done; \ + printf '%s\\n' '{reply}'; sleep 10" + ), + ], + ); + + let mut session = StdioSession::spawn(&script_server(command, args)).await.expect("spawn"); + let resp = session + .request( + "tools/call", + Some(json!({ "name": "slow", "arguments": {} })), + Duration::from_millis(1800), + ) + .await + .expect("progress kept the call alive"); + assert!(resp.result.is_some()); + } + + /// A server that pages its catalogue has every page read, not just the + /// first. + #[tokio::test] + async fn tools_list_follows_the_next_cursor() { + let first = r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"a"}],"nextCursor":"p2"}}"#; + let second = r#"{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"b"}]}}"#; + #[cfg(windows)] + let dir = tempfile::TempDir::new().unwrap(); + #[cfg(windows)] + let (command, args) = { + let script = dir.path().join("server.cmd"); + std::fs::write( + &script, + format!( + "@set /p _=\r\n@echo {first}\r\n@set /p _=\r\n@echo {second}\r\n\ + @ping -n 10 127.0.0.1 >nul\r\n" + ), + ) + .unwrap(); + (script.display().to_string(), Vec::<String>::new()) + }; + #[cfg(unix)] + let (command, args) = ( + "sh".to_string(), + vec![ + "-c".to_string(), + format!("read _; printf '%s\\n' '{first}'; read _; printf '%s\\n' '{second}'; sleep 10"), + ], + ); + + let mut session = StdioSession::spawn(&script_server(command, args)).await.expect("spawn"); + let names: Vec<String> = session + .list_tools_raw() + .await + .expect("both pages") + .into_iter() + .map(|t| t.name) + .collect(); + assert_eq!(names, ["a", "b"]); + } + + /// A reply Loach can't parse fails its call as unreadable — not as a + /// server that exited, which got a running server stopped. + #[tokio::test] + async fn a_malformed_reply_reaches_its_request() { + let reply = r#"{"jsonrpc":"2.0","id":1,"error":"boom"}"#; + #[cfg(windows)] + let dir = tempfile::TempDir::new().unwrap(); + #[cfg(windows)] + let (command, args) = { + // `set /p` takes our request off stdin before the reply goes out. + let script = dir.path().join("server.cmd"); + std::fs::write(&script, format!("@set /p _=\r\n@echo {reply}\r\n@ping -n 10 127.0.0.1 >nul\r\n")) + .unwrap(); + (script.display().to_string(), Vec::<String>::new()) + }; + #[cfg(unix)] + let (command, args) = ( + "sh".to_string(), + vec!["-c".to_string(), format!("read _; printf '%s\\n' '{reply}'; sleep 10")], + ); + + let mut session = StdioSession::spawn(&script_server(command, args)).await.expect("spawn"); + let e = session + .request("tools/list", None, Duration::from_secs(10)) + .await + .expect_err("the reply is malformed"); + assert!(is_unreadable_reply(&e), "{e:#}"); + assert!(format!("{e:#}").contains("valid JSON-RPC response"), "{e:#}"); + assert!(session.is_alive()); + } + #[test] fn a_reply_timeout_is_recognised_through_its_context() { let e = anyhow::Error::new(ReplyTimeout).context("MCP server `x` did not reply"); diff --git a/src-tauri/src/mcp/types.rs b/src-tauri/src/mcp/types.rs index 0e057a2..1295343 100644 --- a/src-tauri/src/mcp/types.rs +++ b/src-tauri/src/mcp/types.rs @@ -6,6 +6,18 @@ use serde_json::Value; /// that don't support exactly this version usually negotiate down. pub const PROTOCOL_VERSION: &str = "2025-06-18"; +/// Most `tools/list` pages followed for one server. A server that paginates +/// hands out its catalogue a page at a time, and only the first used to be +/// read; this bounds one that never stops handing out cursors. Past it the +/// aggregate keeps only `MAX_TOOLS_PER_SERVER` anyway. +pub const MAX_TOOL_PAGES: usize = 10; + +/// Longest a `tools/call` may run while the server keeps reporting progress +/// on it. Each `notifications/progress` for the call restarts its usual +/// per-request timeout, so a long tool that says it's still working isn't +/// cut off at 30 s — but one that reports forever still is, here. +pub const MAX_TOOL_CALL_TIME: std::time::Duration = std::time::Duration::from_secs(10 * 60); + // ---------- JSON-RPC 2.0 framing ---------- // // Requests / notifications are built with `serde_json::json!(...)` in the @@ -61,6 +73,9 @@ pub struct ServerInfo { pub struct ListToolsResult { #[serde(default)] pub tools: Vec<McpToolRaw>, + /// Where the next page starts, when there is one. + #[serde(default, rename = "nextCursor")] + pub next_cursor: Option<String>, } #[derive(Debug, Deserialize)] diff --git a/src-tauri/src/ollama_launch.rs b/src-tauri/src/ollama_launch.rs index 60327a2..64ae994 100644 --- a/src-tauri/src/ollama_launch.rs +++ b/src-tauri/src/ollama_launch.rs @@ -32,6 +32,14 @@ const POLL_INTERVAL: Duration = Duration::from_millis(300); /// the server answers — including the common case where it was already up /// and nothing was spawned. pub async fn start(http: &Client, base_url: &str) -> Result<(), String> { + // One start at a time. Auto-launch on boot and a "Start Ollama" click + // could overlap: both found the server down, both spawned one, and the + // second — unable to bind the port the first now held — reported + // "exited immediately" while the first came up fine. A start that waits + // here finds the first one's server answering and returns. + static STARTING: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + let _one_at_a_time = STARTING.lock().await; + // Everything we PROBE goes to a connectable address; everything we // CONFIGURE keeps the user's own host. They differ for `0.0.0.0` — see // `probe_url`. @@ -134,13 +142,17 @@ fn probe_url(base_url: &str) -> String { /// `host:port` for the spawned server's `OLLAMA_HOST`, from the user's /// configured base URL. Returns `None` when the URL has no usable host. +/// +/// The port is always spelled out. A URL without one is dialled — by the +/// readiness probe and by every request after it — on its scheme's default +/// (80 / 443), but a bare `OLLAMA_HOST` host makes Ollama pick its own +/// 11434: the probe timed out after 20 s and left that server running where +/// nothing would ever look. fn ollama_host_env(base_url: &str) -> Option<String> { let url = reqwest::Url::parse(base_url).ok()?; let host = url.host_str()?; - match url.port() { - Some(port) => Some(format!("{host}:{port}")), - None => Some(host.to_string()), - } + let port = url.port_or_known_default()?; + Some(format!("{host}:{port}")) } /// Whether `base_url` names this machine. Deliberately stricter than @@ -353,10 +365,15 @@ mod tests { ollama_host_env("http://localhost:11434").as_deref(), Some("localhost:11434") ); - // No explicit port — hand Ollama the bare host and let it default. + // No explicit port: bind where the probe and the app will dial, not + // on Ollama's own default of 11434. assert_eq!( ollama_host_env("http://localhost").as_deref(), - Some("localhost") + Some("localhost:80") + ); + assert_eq!( + ollama_host_env("http://[::1]").as_deref(), + Some("[::1]:80") ); assert_eq!(ollama_host_env("not a url"), None); } diff --git a/src-tauri/src/preload.rs b/src-tauri/src/preload.rs index f59517a..4a9392f 100644 --- a/src-tauri/src/preload.rs +++ b/src-tauri/src/preload.rs @@ -341,6 +341,8 @@ mod tests { label: None, folder_id: None, workspace_root: None, + retrieval_mode: None, + image_scaling_json: None, created_at: 0, updated_at, } diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index dff8c90..65edb54 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -3,6 +3,7 @@ pub mod openai; use std::time::Duration; +use futures_util::{FutureExt, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tauri::{AppHandle, Emitter}; @@ -102,7 +103,9 @@ pub(super) struct ToolOutcome { /// Returns `None` when the stream was cancelled while waiting — for an /// answer or for the tool — in which case nothing has been emitted for /// this call beyond `ToolCall`, and the caller emits `Cancelled` and -/// returns exactly as it did before. +/// returns exactly as it did before. The one exception is a workspace tool +/// that changes files: once dispatched it runs to the end whatever Stop +/// does, so its `ToolResult` goes out first and the change is on record. pub(super) async fn execute_tool_call( ctx: &ToolCallCtx<'_>, call_id: &str, @@ -238,26 +241,37 @@ pub(super) async fn execute_tool_call( args, ctx.workspace_root, ); - let (content, is_error, attachments) = select! { - biased; - _ = ctx.cancel.notified() => return None, - r = dispatch => match r { - Ok(r) => (r.content_text, r.is_error, r.attachments), - Err(e) => { - // The whole error, a stdio server's stderr tail included, - // goes to the log. The model — and the transcript, and so - // exports — get it cut before the stderr, which can quote - // anything the server printed, a key in a URL included. - // Settings → MCP → Test connection shows it in full. - let full = format!("{e:#}"); - tracing::warn!("tool `{}` failed: {full}", tool_def.qualified_name); - ( - format!("tool call failed: {}", crate::mcp::stdio::without_stderr(&full)), - true, - Vec::new(), - ) - } - }, + // A write, edit, move or delete isn't raced against Stop: its blocking + // task can't be stopped once started (see `dispatch_builtin_guarded`), + // so dropping it here only lost the record of a change that still + // landed on disk. It finishes, its result goes out like any other, and + // a Stop pressed meanwhile — a stored permit — is honoured right after. + let mutates = builtin && crate::tools::fs::requires_approval(tool_name); + let outcome = if mutates { + dispatch.await + } else { + select! { + biased; + _ = ctx.cancel.notified() => return None, + r = dispatch => r, + } + }; + let (content, is_error, attachments) = match outcome { + Ok(r) => (r.content_text, r.is_error, r.attachments), + Err(e) => { + // The whole error, a stdio server's stderr tail included, + // goes to the log. The model — and the transcript, and so + // exports — get it cut before the stderr, which can quote + // anything the server printed, a key in a URL included. + // Settings → MCP → Test connection shows it in full. + let full = format!("{e:#}"); + tracing::warn!("tool `{}` failed: {full}", tool_def.qualified_name); + ( + format!("tool call failed: {}", crate::mcp::stdio::without_stderr(&full)), + true, + Vec::new(), + ) + } }; // Cap what we feed back to the model. The UI gets the original @@ -275,6 +289,9 @@ pub(super) async fn execute_tool_call( timed_out: false, }, ); + if mutates && ctx.cancel.notified().now_or_never().is_some() { + return None; + } Some(ToolOutcome { for_model }) } @@ -305,6 +322,45 @@ pub(super) fn cap_tool_text(s: &str, max_bytes: usize) -> String { ) } +/// Most of a failed response's body that goes into an error message. The +/// message lands in a `StreamEvent::Error`, which the transcript keeps, and a +/// base URL pointed at the wrong thing answers with a whole HTML page. A +/// provider's own error JSON is far shorter. +const MAX_ERROR_BODY_BYTES: usize = 4 * 1024; + +/// How long reading that body may take. The shared client carries no +/// timeout (generations run long), so a server that sent its status and then +/// trickled the body held the turn open with it. +const ERROR_BODY_TIMEOUT: Duration = Duration::from_secs(5); + +/// The start of a failed response's body, for an error message: at most +/// [`MAX_ERROR_BODY_BYTES`] of it, read for at most [`ERROR_BODY_TIMEOUT`]. +pub(super) async fn error_body(resp: reqwest::Response) -> String { + let mut stream = resp.bytes_stream(); + let mut raw: Vec<u8> = Vec::new(); + let _ = tokio::time::timeout(ERROR_BODY_TIMEOUT, async { + while raw.len() <= MAX_ERROR_BODY_BYTES { + match stream.next().await { + Some(Ok(chunk)) => raw.extend_from_slice(&chunk), + _ => break, + } + } + }) + .await; + clip_error_body(&raw) +} + +/// `raw` as trimmed text, cut at [`MAX_ERROR_BODY_BYTES`] with a `…` to say so. +fn clip_error_body(raw: &[u8]) -> String { + let text = String::from_utf8_lossy(&raw[..raw.len().min(MAX_ERROR_BODY_BYTES)]); + if raw.len() > MAX_ERROR_BODY_BYTES { + // The cut can split a multi-byte character; drop what's left of it. + format!("{}…", text.trim_end_matches('\u{FFFD}').trim()) + } else { + text.trim().to_string() + } +} + /// Soft ceiling on the serialized `messages` array we'll ship to the /// provider. After a few tool turns with multi-KB results, the running /// history grows fast — every subsequent call has to re-send the whole @@ -337,42 +393,57 @@ fn messages_payload_size(messages: &[Value]) -> usize { /// had said. The stub keeps the tool call's place in the transcript so /// the model still sees the call→reply rhythm. /// -/// `search_from` advances each iteration so we never re-stub the same -/// slot (the stub text itself is >64 bytes, which would otherwise still -/// satisfy `is_substantive_tool_msg` and trap the loop spinning forever -/// in the rare case where one stub alone isn't enough to get back under -/// the cap — e.g. three multi-MB tool results in a row). +/// Nor the results of the turn that just ran — the tool messages after the +/// last other one, which the model is about to read. Images and inlined +/// files count toward the cap too, and once they alone filled it, every +/// result was stubbed, the newest included: the model never saw what it +/// asked for, asked again, and ran into the turn cap. +/// +/// One pass, oldest first, each slot visited once: the stub text itself +/// is >64 bytes and still satisfies `is_substantive_tool_msg`, so +/// re-searching from the start would find the same slot forever when one +/// stub alone isn't enough (e.g. three multi-MB tool results in a row). The +/// size is serialised once and then adjusted per stub, not re-serialised +/// each time. pub(super) fn bound_messages_payload(messages: &mut [Value]) { - let mut search_from: usize = 0; - while messages_payload_size(messages) > SOFT_MESSAGES_CAP_BYTES { - let mut found_idx: Option<usize> = None; - for i in search_from..messages.len() { - if is_substantive_tool_msg(&messages[i]) { - found_idx = Some(i); - break; - } + let mut size = messages_payload_size(messages); + if size <= SOFT_MESSAGES_CAP_BYTES { + return; + } + let current_turn = messages + .iter() + .rposition(|m| m.get("role").and_then(Value::as_str) != Some("tool")) + .map_or(0, |i| i + 1); + for m in &mut messages[..current_turn] { + if size <= SOFT_MESSAGES_CAP_BYTES { + return; } - match found_idx { - Some(i) => { - stub_tool_message(&mut messages[i]); - search_from = i + 1; - } - None => { - // Nothing left to drop — the system / user / assistant - // turns alone exceed the cap, or every tool message is - // already either short or stubbed. Bail rather than - // spinning; let the provider's own context-length error - // surface to the user if it doesn't fit. - tracing::warn!( - "messages payload {} bytes exceeds cap of {} bytes after \ - dropping every tool result — sending as-is", - messages_payload_size(messages), - SOFT_MESSAGES_CAP_BYTES - ); - break; - } + if is_substantive_tool_msg(m) { + let before = content_size(m); + stub_tool_message(m); + size = size - before + content_size(m); } } + if size > SOFT_MESSAGES_CAP_BYTES { + // Nothing left to drop — the system / user / assistant turns and + // this turn's results alone exceed the cap, or every earlier tool + // message is already either short or stubbed. Let the provider's + // own context-length error surface to the user if it doesn't fit. + tracing::warn!( + "messages payload {size} bytes exceeds cap of {} bytes after \ + dropping every earlier tool result — sending as-is", + SOFT_MESSAGES_CAP_BYTES + ); + } +} + +/// Serialised size of a message's `content`, the only part a stub changes. +/// Compact JSON writes a value the same way wherever it sits, so the +/// difference before and after is exactly what the whole array changes by. +fn content_size(m: &Value) -> usize { + m.get("content") + .and_then(|c| serde_json::to_vec(c).ok()) + .map_or(0, |v| v.len()) } fn is_substantive_tool_msg(m: &Value) -> bool { @@ -418,7 +489,7 @@ fn stub_tool_message(m: &mut Value) { /// during connect. If DNS resolution itself fails we let the request /// proceed — `reqwest::send` will surface a more accurate error than we /// could synthesize here. -pub(super) async fn refuse_link_local_host(base_url: &str) -> Result<(), String> { +pub(crate) async fn refuse_link_local_host(base_url: &str) -> Result<(), String> { use std::net::IpAddr; let url = reqwest::Url::parse(base_url) .map_err(|e| format!("could not parse base URL: {e}"))?; @@ -558,8 +629,8 @@ mod tests { // used `iter_mut().find(...)` and re-found the same just-stubbed // slot on every iteration — spinning forever because the stub // text itself is >64 bytes and `is_substantive_tool_msg` still - // matched it. With `search_from` advancing per iteration, each - // slot is visited at most once and the loop terminates. + // matched it. With a single forward pass, each slot is visited at + // most once and the loop terminates. let big = "y".repeat(SOFT_MESSAGES_CAP_BYTES / 2); let mut msgs = vec![ json!({ "role": "user", "content": "go" }), @@ -618,6 +689,47 @@ mod tests { assert!(out.starts_with("🦀\n"), "{out:?}"); } + #[test] + fn clip_error_body_trims_and_cuts_on_a_char_boundary() { + assert_eq!(clip_error_body(b" {\"error\":\"model not found\"}\n"), r#"{"error":"model not found"}"#); + // "é" is two bytes; one straddling the cut leaves no stray half. + let mut raw = vec![b'x'; MAX_ERROR_BODY_BYTES - 1]; + raw.extend_from_slice("é tail".as_bytes()); + let out = clip_error_body(&raw); + assert!(out.ends_with("x…"), "{}", &out[out.len() - 8..]); + assert_eq!(out.len(), MAX_ERROR_BODY_BYTES - 1 + '…'.len_utf8()); + } + + /// A base URL aimed at a web server answers with a page, not an error + /// object. Only its start reaches the message — and the transcript. + #[tokio::test] + async fn error_body_reads_only_the_start_of_a_large_page() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let port = listener.local_addr().expect("addr").port(); + tokio::spawn(async move { + let Ok((mut conn, _)) = listener.accept().await else { return }; + let mut request = [0u8; 4096]; + let _ = conn.read(&mut request).await; + let page = format!("<html>{}</html>", "x".repeat(1024 * 1024)); + let head = format!( + "HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/html\r\ncontent-length: {}\r\n\r\n", + page.len() + ); + let _ = conn.write_all(head.as_bytes()).await; + let _ = conn.write_all(page.as_bytes()).await; + }); + let resp = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/v1/chat/completions")) + .send() + .await + .expect("send"); + let body = error_body(resp).await; + assert!(body.starts_with("<html>xxx"), "{}", &body[..20]); + assert!(body.ends_with('…')); + assert!(body.len() <= MAX_ERROR_BODY_BYTES + '…'.len_utf8()); + } + #[test] fn bound_messages_payload_leaves_short_tool_messages_alone() { // Even when the array is over the cap, short tool messages stay — @@ -631,6 +743,31 @@ mod tests { bound_messages_payload(&mut msgs); assert_eq!(msgs[1], short_tool); } + + #[test] + fn bound_messages_payload_never_stubs_the_results_the_model_is_about_to_read() { + // An attached image alone fills the cap. Earlier results go, but the + // ones this turn produced stay: stubbing them too had the model ask + // for the same thing again until it hit the turn cap. + let image = "i".repeat(SOFT_MESSAGES_CAP_BYTES); + let result = "r".repeat(1024); + let mut msgs = vec![ + json!({ "role": "user", "content": image }), + json!({ "role": "assistant", "content": null, "tool_calls": [] }), + json!({ "role": "tool", "tool_call_id": "1", "content": result.clone() }), + json!({ "role": "assistant", "content": null, "tool_calls": [] }), + json!({ "role": "tool", "tool_call_id": "2", "content": result.clone() }), + json!({ "role": "tool", "tool_call_id": "3", "content": result.clone() }), + ]; + bound_messages_payload(&mut msgs); + assert!( + msgs[2]["content"].as_str().unwrap().contains("dropped to keep the conversation"), + "an earlier turn's result is stubbed: {:?}", + msgs[2] + ); + assert_eq!(msgs[4]["content"], result.as_str()); + assert_eq!(msgs[5]["content"], result.as_str()); + } } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -669,9 +806,11 @@ pub struct GenerationParams { /// Reasoning toggle for thinking-capable models (Qwen3, DeepSeek-R1, /// GPT-OSS, …). `Some(true)` forces a chain-of-thought before the /// reply; `Some(false)` suppresses it; `None` lets Ollama use the - /// model's own default. Models without thinking capability ignore the - /// flag — Ollama returns an error for them, but the frontend gates the - /// toggle on the model's `capabilities` so we never send it for those. + /// model's own default. Ollama answers the flag with an error for a + /// model that can't think, so it should only be set for models whose + /// `capabilities` include `thinking` — and when one slips through (an + /// older build that doesn't report capabilities), the Ollama provider + /// drops it and retries. #[serde(default)] pub think: Option<bool>, /// Number of model layers to offload to the GPU (Ollama `num_gpu`). diff --git a/src-tauri/src/providers/ollama.rs b/src-tauri/src/providers/ollama.rs index c3363fc..a69d0ad 100644 --- a/src-tauri/src/providers/ollama.rs +++ b/src-tauri/src/providers/ollama.rs @@ -199,7 +199,7 @@ async fn drive_progress_stream( biased; _ = cancel.notified() => { let _ = app.emit(&channel, AdminEvent::Cancelled); - registry.finish(&stream_id); + registry.finish_own(&stream_id, &cancel); return Ok(()); } r = http.post(&url).json(&body).send() => r, @@ -213,21 +213,21 @@ async fn drive_progress_stream( message: format!("Ollama request failed: {e}"), }, ); - registry.finish(&stream_id); + registry.finish_own(&stream_id, &cancel); return Err(e.into()); } }; if !resp.status().is_success() { let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); + let text = super::error_body(resp).await; let _ = app.emit( &channel, AdminEvent::Error { message: format!("Ollama HTTP {status}: {text}"), }, ); - registry.finish(&stream_id); + registry.finish_own(&stream_id, &cancel); return Err(anyhow!("ollama http error")); } @@ -248,7 +248,7 @@ async fn drive_progress_stream( // to notice the client gave up and abort the download. drop(byte_stream); let _ = app.emit(&channel, AdminEvent::Cancelled); - registry.finish(&stream_id); + registry.finish_own(&stream_id, &cancel); return Ok(()); } maybe = byte_stream.next() => { @@ -268,7 +268,7 @@ async fn drive_progress_stream( ), }, ); - registry.finish(&stream_id); + registry.finish_own(&stream_id, &cancel); return Err(anyhow!("ollama admin stream frame too large")); } while let Some(pos) = buf.iter().position(|b| *b == b'\n') { @@ -282,7 +282,7 @@ async fn drive_progress_stream( &channel, AdminEvent::Error { message: err }, ); - registry.finish(&stream_id); + registry.finish_own(&stream_id, &cancel); return Ok(()); } let status = parsed.status.unwrap_or_else(|| "working".into()); @@ -298,7 +298,7 @@ async fn drive_progress_stream( ); if done_now { let _ = app.emit(&channel, AdminEvent::Done); - registry.finish(&stream_id); + registry.finish_own(&stream_id, &cancel); return Ok(()); } } @@ -313,7 +313,7 @@ async fn drive_progress_stream( &channel, AdminEvent::Error { message: format!("stream error: {e}") }, ); - registry.finish(&stream_id); + registry.finish_own(&stream_id, &cancel); return Err(e.into()); } None => { @@ -321,7 +321,7 @@ async fn drive_progress_stream( // treat EOF as a successful completion so callers don't // hang forever on a silently-closed connection. let _ = app.emit(&channel, AdminEvent::Done); - registry.finish(&stream_id); + registry.finish_own(&stream_id, &cancel); return Ok(()); } } @@ -347,7 +347,7 @@ pub async fn pull_model( &admin_channel(&stream_id), AdminEvent::Error { message: e }, ); - registry.finish(&stream_id); + registry.finish_own(&stream_id, &cancel); return Ok(()); } let url = format!("{}/api/pull", base_url.trim_end_matches('/')); @@ -370,21 +370,135 @@ pub async fn create_model( &admin_channel(&stream_id), AdminEvent::Error { message: e }, ); - registry.finish(&stream_id); + registry.finish_own(&stream_id, &cancel); return Ok(()); } let url = format!("{}/api/create", base_url.trim_end_matches('/')); - // Ollama 0.3+ supports `modelfile` as a field on `/api/create` so we - // don't need to write a temp file. `stream: true` keeps the NDJSON - // progress frames flowing. - let body = serde_json::json!({ - "name": name, - "modelfile": modelfile, - "stream": true, - }); + // Ollama 0.5.5 replaced `/api/create`'s raw `modelfile` field with + // structured ones (`from`, `system`, `template`, `parameters`) and + // rejects a request without `from`. Send both: current servers read the + // structured fields, older ones ignore them and read `modelfile`. + // `stream: true` keeps the NDJSON progress frames flowing. + let mut body = match create_fields(modelfile) { + Ok(fields) => Value::Object(fields), + Err(e) => { + let _ = app.emit( + &admin_channel(&stream_id), + AdminEvent::Error { + message: format!("Couldn't read the Modelfile: {e}"), + }, + ); + registry.finish_own(&stream_id, &cancel); + return Ok(()); + } + }; + body["model"] = json!(name); + body["name"] = json!(name); + body["modelfile"] = json!(modelfile); + body["stream"] = json!(true); drive_progress_stream(app, registry, stream_id, url, body, http, cancel).await } +/// Split the Models editor's Modelfile into `/api/create`'s structured +/// fields. Covers the directives `buildModelfile` (src/lib/modelfile.ts) +/// emits: FROM; SYSTEM and TEMPLATE, `"""`-quoted and possibly multi-line; +/// and PARAMETER, numeric except `stop`, whose quoted values collect into a +/// list. +fn create_fields(modelfile: &str) -> std::result::Result<serde_json::Map<String, Value>, String> { + let mut out = serde_json::Map::new(); + let mut params = serde_json::Map::new(); + let mut stops: Vec<Value> = Vec::new(); + let mut rest = modelfile; + loop { + rest = rest.trim_start(); + if rest.is_empty() { + break; + } + let split = rest.find(char::is_whitespace).unwrap_or(rest.len()); + let (directive, after) = rest.split_at(split); + match directive.to_ascii_uppercase().as_str() { + "FROM" => { + let (line, next) = take_line(after); + out.insert("from".into(), json!(line.trim())); + rest = next; + } + d @ ("SYSTEM" | "TEMPLATE") => { + let after = after.trim_start_matches([' ', '\t']); + let (value, next) = match after.strip_prefix("\"\"\"") { + Some(block) => { + let end = block + .find("\"\"\"") + .ok_or_else(|| format!("{d} block has no closing \"\"\""))?; + (&block[..end], &block[end + 3..]) + } + None => { + let (line, next) = take_line(after); + (line.trim(), next) + } + }; + out.insert(d.to_ascii_lowercase(), json!(value)); + rest = next; + } + "PARAMETER" => { + let (line, next) = take_line(after); + rest = next; + let line = line.trim(); + let (key, value) = line + .split_once(char::is_whitespace) + .ok_or_else(|| format!("PARAMETER `{line}` has no value"))?; + let value = value.trim(); + if key == "stop" { + stops.push(json!(unquote(value))); + } else if let Ok(n) = value.parse::<i64>() { + params.insert(key.into(), json!(n)); + } else if let Ok(f) = value.parse::<f64>() { + params.insert(key.into(), json!(f)); + } else { + return Err(format!("PARAMETER {key} isn't a number: {value}")); + } + } + other => return Err(format!("unsupported directive `{other}`")), + } + } + if !out.contains_key("from") { + return Err("no FROM line".into()); + } + if !stops.is_empty() { + params.insert("stop".into(), Value::Array(stops)); + } + if !params.is_empty() { + out.insert("parameters".into(), Value::Object(params)); + } + Ok(out) +} + +fn take_line(s: &str) -> (&str, &str) { + match s.find('\n') { + Some(i) => (&s[..i], &s[i + 1..]), + None => (s, ""), + } +} + +/// `"a\"b"` → `a"b`: reverses the quoting `buildModelfile` applies to `stop`. +fn unquote(v: &str) -> String { + let inner = v + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(v); + let mut out = String::with_capacity(inner.len()); + let mut chars = inner.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(escaped) = chars.next() { + out.push(escaped); + continue; + } + } + out.push(c); + } + out +} + #[derive(Debug, Deserialize)] struct TagsResponse { models: Vec<TagModel>, @@ -543,6 +657,12 @@ struct OllamaChunk { /// builds that don't report it. #[serde(default)] eval_duration: Option<u64>, + /// What went wrong when Ollama fails mid-reply (the runner crashed or + /// ran out of memory): it writes one last `{"error":"…"}` line and + /// closes the stream without a `done: true`. Read as an empty chunk, + /// that cut-off reply ended as a normal completion. + #[serde(default)] + error: Option<String>, } #[derive(Debug, Deserialize)] @@ -613,7 +733,7 @@ pub async fn chat_stream( // there) while allowing loopback, RFC 1918, CGNAT, and public IPs. if let Err(e) = super::refuse_link_local_host(&req.base_url).await { let _ = app.emit(&channel, StreamEvent::Error { message: e }); - registry.finish(&req.stream_id); + registry.finish_own(&req.stream_id, &cancel); return Ok(()); } @@ -693,6 +813,9 @@ pub async fn chat_stream( body["tools"] = tools.clone(); } + // What the model says this turn, kept for the history should the + // turn end in tool calls. + let mut turn_text = String::new(); let outcome = match run_one_turn( &app, &http, @@ -704,6 +827,7 @@ pub async fn chat_stream( &mut reported_tokens, &mut reported_eval_ns, &mut think_already_drop, + &mut turn_text, &req.stream_id, ®istry, ) @@ -726,7 +850,7 @@ pub async fn chat_stream( tool_ctx.approval_wait(), ); let _ = app.emit(&channel, StreamEvent::Done); - registry.finish(&req.stream_id); + registry.finish_own(&req.stream_id, &cancel); return Ok(()); } // Asked for tools on the last round anyway: nothing would read @@ -734,13 +858,16 @@ pub async fn chat_stream( TurnOutcome::Tools(_) if turn + 1 == MAX_TOOL_TURNS => break, TurnOutcome::Tools(calls) => { // Append the assistant turn including the tool_calls block, - // so subsequent /api/chat calls see the full conversation. - // We use `Value::Null` for content (rather than "") because - // strict OpenAI-compat gateways reject empty strings here; - // recent Ollama accepts both, so Null is the safer choice. + // so subsequent /api/chat calls see the full conversation — + // the text the model streamed before its calls included, or + // it saw no trace of that preamble and said it all again. + // We use `Value::Null` for content (rather than "") when it + // said nothing, because strict OpenAI-compat gateways reject + // empty strings here; recent Ollama accepts both, so Null is + // the safer choice. messages.push(json!({ "role": "assistant", - "content": Value::Null, + "content": if turn_text.is_empty() { Value::Null } else { json!(turn_text) }, "tool_calls": calls .iter() .map(serialise_tool_call) @@ -801,7 +928,7 @@ pub async fn chat_stream( .await else { let _ = app.emit(&channel, StreamEvent::Cancelled); - registry.finish(&req.stream_id); + registry.finish_own(&req.stream_id, &cancel); return Ok(()); }; @@ -835,7 +962,7 @@ pub async fn chat_stream( ), }, ); - registry.finish(&req.stream_id); + registry.finish_own(&req.stream_id, &cancel); Ok(()) } @@ -855,6 +982,7 @@ async fn run_one_turn( reported_tokens: &mut Option<u32>, reported_eval_ns: &mut Option<u64>, think_already_drop: &mut bool, + turn_text: &mut String, stream_id: &str, registry: &StreamRegistry, ) -> Result<Option<TurnOutcome>> { @@ -870,7 +998,7 @@ async fn run_one_turn( biased; _ = cancel.notified() => { let _ = app.emit(channel, StreamEvent::Cancelled); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Ok(None); } r = http.post(&url).json(body).send() => r, @@ -884,7 +1012,7 @@ async fn run_one_turn( message: format!("Ollama request failed: {e}"), }, ); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Err(e.into()); } }; @@ -897,7 +1025,7 @@ async fn run_one_turn( // once before giving up. if !resp.status().is_success() && body.get("think").is_some() { let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); + let text = super::error_body(resp).await; if text.contains("does not support thinking") { if let Some(obj) = body.as_object_mut() { obj.remove("think"); @@ -912,7 +1040,7 @@ async fn run_one_turn( biased; _ = cancel.notified() => { let _ = app.emit(channel, StreamEvent::Cancelled); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Ok(None); } r = http.post(&url).json(body).send() => r, @@ -926,7 +1054,7 @@ async fn run_one_turn( message: format!("Ollama request failed: {e}"), }, ); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Err(e.into()); } }; @@ -937,21 +1065,21 @@ async fn run_one_turn( message: format!("Ollama HTTP {status}: {text}"), }, ); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Err(anyhow!("ollama http error")); } } if !resp.status().is_success() { let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); + let text = super::error_body(resp).await; let _ = app.emit( channel, StreamEvent::Error { message: format!("Ollama HTTP {status}: {text}"), }, ); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Err(anyhow!("ollama http error")); } @@ -975,7 +1103,7 @@ async fn run_one_turn( _ = cancel.notified() => { drop(byte_stream); let _ = app.emit(channel, StreamEvent::Cancelled); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Ok(None); } maybe = byte_stream.next() => { @@ -992,19 +1120,24 @@ async fn run_one_turn( ), }, ); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Err(anyhow!("ollama chat stream frame too large")); } let mut pending_token = String::new(); let mut pending_think = String::new(); let mut finished = false; + let mut stream_error: Option<String> = None; while let Some(pos) = buf.iter().position(|b| *b == b'\n') { let line: Vec<u8> = buf.drain(..=pos).collect(); let line = &line[..line.len() - 1]; if line.is_empty() { continue; } match serde_json::from_slice::<OllamaChunk>(line) { Ok(parsed) => { + if let Some(e) = parsed.error { + stream_error = Some(e); + break; + } if let Some(n) = parsed.eval_count { turn_eval = Some(n); } @@ -1021,6 +1154,7 @@ async fn run_one_turn( if !delta.is_empty() { *total_tokens += 1; pending_token.push_str(&delta); + turn_text.push_str(&delta); } } if !msg.tool_calls.is_empty() { @@ -1066,6 +1200,16 @@ async fn run_one_turn( StreamEvent::Token { delta: pending_token }, ); } + // The text so far went out above; the reply ends + // here as an error, not Done. + if let Some(e) = stream_error { + let _ = app.emit( + channel, + StreamEvent::Error { message: format!("Ollama stopped mid-reply: {e}") }, + ); + registry.finish_own(stream_id, cancel); + return Err(anyhow!("ollama stream error: {e}")); + } if finished { // Fold this turn's reported count (if any) // into the session total. Done here, not @@ -1089,7 +1233,7 @@ async fn run_one_turn( channel, StreamEvent::Error { message: format!("stream error: {e}") }, ); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Err(e.into()); } None => { @@ -1315,6 +1459,47 @@ fn normalise_args(v: &Value) -> Value { mod tests { use super::*; + // --- create_fields -------------------------------------------------------- + // Ollama 0.5.5+ rejects `/api/create` without the structured fields, so + // the editor's Modelfile has to come apart cleanly. + + #[test] + fn create_fields_splits_the_editors_modelfile() { + let modelfile = "FROM llama3.1:8b\n\n\ + SYSTEM \"\"\"You are terse.\nAnswer in one line.\"\"\"\n\n\ + TEMPLATE \"\"\"{{ .Prompt }}\"\"\"\n\n\ + PARAMETER temperature 0.7\n\ + PARAMETER num_ctx 32768\n\ + PARAMETER stop \"<|end|>\"\n\ + PARAMETER stop \"say \\\"hi\\\" \\\\ bye\"\n"; + let fields = create_fields(modelfile).expect("parses"); + assert_eq!(fields["from"], "llama3.1:8b"); + assert_eq!(fields["system"], "You are terse.\nAnswer in one line."); + assert_eq!(fields["template"], "{{ .Prompt }}"); + assert_eq!( + fields["parameters"], + json!({ + "temperature": 0.7, + "num_ctx": 32768, + "stop": ["<|end|>", "say \"hi\" \\ bye"], + }) + ); + } + + #[test] + fn create_fields_omits_what_the_form_left_empty() { + let fields = create_fields("FROM qwen3:8b\n").expect("parses"); + assert_eq!(Value::Object(fields), json!({ "from": "qwen3:8b" })); + } + + #[test] + fn create_fields_refuses_what_it_cant_send() { + assert!(create_fields("PARAMETER temperature 0.7\n").is_err()); + assert!(create_fields("FROM m\nSYSTEM \"\"\"never closed\n").is_err()); + assert!(create_fields("FROM m\nADAPTER ./lora.gguf\n").is_err()); + assert!(create_fields("FROM m\nPARAMETER temperature warm\n").is_err()); + } + // --- normalise_args ------------------------------------------------------- // Ollama mainline hands `arguments` as a parsed object, but some builds // and the OpenAI-compat passes hand a stringified JSON. The dispatcher @@ -1374,5 +1559,18 @@ mod tests { json!({ "function": { "name": "", "arguments": {} } }) ); } + + // --- OllamaChunk ------------------------------------------------------------ + + #[test] + fn a_mid_stream_error_line_is_read_as_an_error() { + // What Ollama writes when the runner dies mid-reply: no `done`, no + // message — it used to parse as an empty chunk and end as Done. + let chunk: OllamaChunk = + serde_json::from_str(r#"{"error":"llama runner process has terminated"}"#) + .expect("parses"); + assert_eq!(chunk.error.as_deref(), Some("llama runner process has terminated")); + assert!(!chunk.done); + } } diff --git a/src-tauri/src/providers/openai.rs b/src-tauri/src/providers/openai.rs index 5ce3322..3b635c0 100644 --- a/src-tauri/src/providers/openai.rs +++ b/src-tauri/src/providers/openai.rs @@ -13,7 +13,7 @@ use super::{resolve_qualified, ChatRequest, ModelInfo}; use crate::db::Database; use crate::mcp::McpToolDef; use crate::secrets; -use crate::sse::find_frame_end; +use crate::sse::{find_frame_end, frame_lines}; use crate::stream::{event_channel, StreamEvent, StreamRegistry}; /// Per-frame ceiling on the SSE buffer between blank-line delimiters. Mirrors @@ -50,6 +50,12 @@ const MAX_TOOL_CALLS: usize = 256; /// the accumulation bounded. const MAX_TOOL_CALL_BYTES: usize = 4 * 1024 * 1024; +/// How long a reply may run on past its `finish_reason` for the `usage` +/// frame, which `include_usage` servers send after the finish chunk and +/// before `[DONE]`. Only a server that skips `[DONE]` *and* holds the +/// connection open waits this out; the rest end on `[DONE]` or the close. +const USAGE_GRACE: Duration = Duration::from_secs(2); + #[derive(Debug, Deserialize)] struct ModelsResponse { data: Vec<OpenAIModel>, @@ -78,7 +84,14 @@ pub async fn list_models(http: &Client, base_url: &str) -> Result<Vec<ModelInfo> // `openai_base_url` — would ship the key in cleartext on every // model-list refresh, the exact leak the chat path already prevents. // Off the runtime — blocking OS-keyring read (see chat_stream's note). - if let Some(key) = tokio::task::spawn_blocking(secrets::get_openai_key).await?? { + // A keyring that can't be read counts as no key, as it does there: on + // Linux without a Secret Service, failing here kept a keyless LM Studio + // from listing its models while chatting with it worked. + let key = tokio::task::spawn_blocking(|| secrets::get_openai_key().ok().flatten()) + .await + .ok() + .flatten(); + if let Some(key) = key { if !key.is_empty() && is_safe_for_bearer(base_url) { req = req.bearer_auth(key); } @@ -104,8 +117,10 @@ struct Choice { delta: Option<DeltaMsg>, /// Present on the terminator frame for compat servers that omit the /// trailing `data: [DONE]` marker (Azure, some Ollama-OpenAI proxies). - /// We treat any non-null `finish_reason` as an EOS so the chat doesn't - /// hang waiting for a [DONE] that never arrives. + /// Any non-null `finish_reason` ends the reply — after at most + /// `USAGE_GRACE` more for the usage frame — so the chat doesn't hang + /// waiting for a [DONE] that never arrives. `"error"` (OpenRouter) ends + /// it as an error. #[serde(default)] finish_reason: Option<String>, } @@ -116,7 +131,12 @@ struct DeltaMsg { content: Option<String>, #[serde(default)] reasoning_content: Option<String>, + /// The same thing under the name Ollama's `/v1`, OpenRouter and newer + /// vLLM use. Read only when `reasoning_content` is absent or empty, so a server + /// sending both doesn't show its reasoning twice. #[serde(default)] + reasoning: Option<String>, + #[serde(default, deserialize_with = "null_as_empty")] tool_calls: Vec<DeltaToolCall>, } @@ -144,7 +164,7 @@ struct DeltaToolFn { #[derive(Debug, Deserialize)] struct SseChunk { - #[serde(default)] + #[serde(default, deserialize_with = "null_as_empty")] choices: Vec<Choice>, /// Some servers emit a final, choices-empty chunk that carries /// `usage: { completion_tokens, prompt_tokens, total_tokens }`. When @@ -154,6 +174,12 @@ struct SseChunk { /// multiple tokens per chunk. #[serde(default)] usage: Option<Usage>, + /// A failure mid-reply. vLLM, llama-server and Ollama's compat endpoint + /// send it as a `data: {"error":{…}}` frame of its own; OpenRouter next + /// to a `finish_reason: "error"`. Read as an empty chunk, the cut-off + /// reply ended as a normal completion. + #[serde(default)] + error: Option<Value>, } #[derive(Debug, Deserialize, Clone, Copy)] @@ -162,6 +188,26 @@ struct Usage { completion_tokens: Option<u32>, } +/// `#[serde(default)]` for a list that also takes an explicit `null` as +/// empty. Some compat servers send `"tool_calls": null` on every delta; +/// failing those frames dropped every token of the reply. +fn null_as_empty<'de, D, T>(d: D) -> std::result::Result<Vec<T>, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Ok(Option::<Vec<T>>::deserialize(d)?.unwrap_or_default()) +} + +/// The text of a mid-stream `error`: its `message` when it's the usual +/// object, the string itself when a server sends just that, else the JSON. +fn stream_error_message(err: &Value) -> String { + err.get("message") + .unwrap_or(err) + .as_str() + .map_or_else(|| err.to_string(), str::to_owned) +} + /// Accumulated tool call across a single turn — `name` and `arguments` may /// arrive in pieces over many SSE frames. We close over them with the /// `index` from the delta. @@ -244,7 +290,7 @@ pub async fn chat_stream( // there. if let Err(e) = super::refuse_link_local_host(&req.base_url).await { let _ = app.emit(&channel, StreamEvent::Error { message: e }); - registry.finish(&req.stream_id); + registry.finish_own(&req.stream_id, &cancel); return Ok(()); } @@ -281,6 +327,10 @@ pub async fn chat_stream( // fallback when usage never arrives — e.g. older Ollama compat // shims that ignore `stream_options.include_usage`). let mut reported_tokens: Option<u32> = None; + // Request fields the server refused earlier in this stream (see + // `refused_field`), re-applied to every later tool turn's body so each + // turn doesn't pay the same rejected round-trip. + let mut refused: Vec<String> = Vec::new(); for turn in 0..MAX_TOOL_TURNS { // Keep the running history under the soft cap. The first turn is @@ -327,7 +377,13 @@ pub async fn chat_stream( body["tool_choice"] = json!("none"); } } + for field in &refused { + apply_refusal(&mut body, field); + } + // What the model says this turn, kept for the history should the + // turn end in tool calls. + let mut turn_text = String::new(); let outcome = match run_one_turn( &app, &http, @@ -336,8 +392,10 @@ pub async fn chat_stream( &cancel, api_key.as_deref(), body, + &mut refused, &mut total_tokens, &mut reported_tokens, + &mut turn_text, &req.stream_id, ®istry, ) @@ -355,7 +413,7 @@ pub async fn chat_stream( let tokens_for_metrics = reported_tokens.unwrap_or(total_tokens); emit_metrics(&app, &channel, tokens_for_metrics, start, tool_ctx.approval_wait()); let _ = app.emit(&channel, StreamEvent::Done); - registry.finish(&req.stream_id); + registry.finish_own(&req.stream_id, &cancel); return Ok(()); } // Asked for tools on the last round anyway (a server that @@ -365,8 +423,9 @@ pub async fn chat_stream( TurnOutcome::Tools(calls) => { // Assistant turn carrying the tool calls. OpenAI requires // the tool_calls array (with `id` and the `function` object) - // and a `content` field that may be empty when the model - // immediately reached for a tool. + // and a `content` field: whatever the model said before + // reaching for the tools — left out, the model saw no trace + // of it and said it all again — or null when it said nothing. let oa_calls: Vec<Value> = calls .iter() .enumerate() @@ -387,7 +446,7 @@ pub async fn chat_stream( // turn is purely tool calls. Strict gateways (Azure, // some local OpenAI-compat proxies) 400 on empty // strings here, so we use Null explicitly. - "content": Value::Null, + "content": if turn_text.is_empty() { Value::Null } else { json!(turn_text) }, "tool_calls": oa_calls, })); @@ -441,7 +500,7 @@ pub async fn chat_stream( .await else { let _ = app.emit(&channel, StreamEvent::Cancelled); - registry.finish(&req.stream_id); + registry.finish_own(&req.stream_id, &cancel); return Ok(()); }; @@ -466,10 +525,67 @@ pub async fn chat_stream( ), }, ); - registry.finish(&req.stream_id); + registry.finish_own(&req.stream_id, &cancel); Ok(()) } +/// Request fields a 400 may name that are safe to adjust and resend: ones +/// Loach sets on its own, never the conversation or the tools. +const ADJUSTABLE_FIELDS: &[&str] = &[ + "stream_options", + "max_tokens", + "max_completion_tokens", + "temperature", + "top_p", + "frequency_penalty", + "presence_penalty", + "seed", +]; + +/// Which field a 400 body refuses, if it's one in `body` that +/// `apply_refusal` can adjust. OpenAI names it in `error.param`; other +/// servers only in the message, so that falls back to OpenAI's wording +/// ("Unsupported parameter: 'max_tokens' …") — plus any mention of +/// `stream_options`, the one strict gateways reject in their own words. +fn refused_field(body: &Value, error_text: &str) -> Option<String> { + let param = serde_json::from_str::<Value>(error_text).ok().and_then(|v| { + v.pointer("/error/param") + .and_then(Value::as_str) + .map(str::to_owned) + }); + ADJUSTABLE_FIELDS + .iter() + .find(|&&field| { + body.get(field).is_some() + && match ¶m { + Some(p) => p == field, + None if field == "stream_options" => error_text.contains(field), + None => [ + "Unsupported parameter: '", + "Unsupported value: '", + "Unrecognized request argument supplied: ", + ] + .iter() + .any(|lead| error_text.contains(&format!("{lead}{field}"))), + } + }) + .map(|field| field.to_string()) +} + +/// Drop `field` from the request, except `max_tokens`, whose cap moves to +/// `max_completion_tokens` — the name OpenAI's reasoning models take. +fn apply_refusal(body: &mut Value, field: &str) { + let Some(obj) = body.as_object_mut() else { + return; + }; + let removed = obj.remove(field); + if field == "max_tokens" { + if let Some(cap) = removed { + obj.insert("max_completion_tokens".into(), cap); + } + } +} + #[allow(clippy::too_many_arguments)] async fn run_one_turn( app: &AppHandle, @@ -479,8 +595,10 @@ async fn run_one_turn( cancel: &Arc<tokio::sync::Notify>, api_key: Option<&str>, mut body: Value, + refused: &mut Vec<String>, total_tokens: &mut u32, reported_tokens: &mut Option<u32>, + turn_text: &mut String, stream_id: &str, registry: &StreamRegistry, ) -> Result<Option<TurnOutcome>> { @@ -489,8 +607,8 @@ async fn run_one_turn( // that re-reports cumulative usage per chunk can't inflate the total. let mut turn_tokens: Option<u32> = None; let url = format!("{}/chat/completions", base_url.trim_end_matches('/')); - // Built through a closure so the `stream_options` retry below can issue a - // second, identically-authenticated request without duplicating the + // Built through a closure so the refused-field retry below can issue + // another, identically-authenticated request without duplicating the // bearer logic. let build_req = |b: &Value| { let mut r = http.post(&url).json(b); @@ -530,7 +648,7 @@ async fn run_one_turn( biased; _ = cancel.notified() => { let _ = app.emit(channel, StreamEvent::Cancelled); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Ok(None); } r = http_req.send() => r, @@ -544,48 +662,22 @@ async fn run_one_turn( message: format!("OpenAI request failed: {e}"), }, ); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Err(e.into()); } }; - // `stream_options` is opt-in usage telemetry, not something the chat - // needs. A strict gateway that rejects unknown arguments would otherwise - // fail the whole turn over a field the user never asked for — so on that - // specific rejection, drop it and retry once. Mirrors the Ollama path's - // `think` retry. Metrics fall back to the chunk counter afterwards. - if resp.status() == reqwest::StatusCode::BAD_REQUEST - && body.get("stream_options").is_some() - { - let text = resp.text().await.unwrap_or_default(); - if text.contains("stream_options") { - tracing::warn!("server rejected `stream_options`; retrying without it"); - if let Some(obj) = body.as_object_mut() { - obj.remove("stream_options"); - } - let retried = select! { - biased; - _ = cancel.notified() => { - let _ = app.emit(channel, StreamEvent::Cancelled); - registry.finish(stream_id); - return Ok(None); - } - r = build_req(&body).send() => r, - }; - resp = match retried { - Ok(r) => r, - Err(e) => { - let _ = app.emit( - channel, - StreamEvent::Error { - message: format!("OpenAI request failed: {e}"), - }, - ); - registry.finish(stream_id); - return Err(e.into()); - } - }; - } else { + // A 400 that names a field Loach added on its own: `stream_options` + // (opt-in usage telemetry strict gateways reject), or a sampling knob a + // model refuses — OpenAI's o-series and GPT-5 reject `max_tokens` (they + // take `max_completion_tokens`) and any non-default `temperature`. The + // whole turn would otherwise fail over a field the user never set, so + // adjust that one field and retry; each field once, recorded in `refused` + // for the stream's later turns. Mirrors the Ollama path's `think` retry. + // Metrics fall back to the chunk counter without `stream_options`. + while resp.status() == reqwest::StatusCode::BAD_REQUEST { + let text = super::error_body(resp).await; + let Some(field) = refused_field(&body, &text).filter(|f| !refused.contains(f)) else { // A 400 about something else. The body is already consumed, so // report it here rather than falling through to the block below. let _ = app.emit( @@ -594,14 +686,39 @@ async fn run_one_turn( message: format!("OpenAI HTTP 400 Bad Request: {text}"), }, ); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Err(anyhow!("openai http error")); - } + }; + tracing::warn!("server refused `{field}`; retrying without it"); + apply_refusal(&mut body, &field); + refused.push(field); + let retried = select! { + biased; + _ = cancel.notified() => { + let _ = app.emit(channel, StreamEvent::Cancelled); + registry.finish_own(stream_id, cancel); + return Ok(None); + } + r = build_req(&body).send() => r, + }; + resp = match retried { + Ok(r) => r, + Err(e) => { + let _ = app.emit( + channel, + StreamEvent::Error { + message: format!("OpenAI request failed: {e}"), + }, + ); + registry.finish_own(stream_id, cancel); + return Err(e.into()); + } + }; } if !resp.status().is_success() { let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); + let text = super::error_body(resp).await; // If the server rejected the request for auth reasons AND we // deliberately withheld the bearer because the base URL is cleartext // to a non-loopback host, surface the *why* — a bare "401" against @@ -622,7 +739,7 @@ async fn run_one_turn( message: format!("OpenAI HTTP {status}: {text}{suffix}"), }, ); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Err(anyhow!("openai http error")); } @@ -634,6 +751,11 @@ async fn run_one_turn( // arguments. Bounded by `MAX_TOOL_CALL_BYTES` in the loop so an untrusted // server can't grow `accum` without limit via a flood of small frames. let mut tool_call_bytes: usize = 0; + // Set once a choice carries a `finish_reason`. The reply's content is + // complete then, but the `usage` frame is still to come, so reading + // goes on until `[DONE]`, the end of the stream, or `usage_deadline`. + let mut finish_seen = false; + let mut usage_deadline: Option<tokio::time::Instant> = None; loop { select! { @@ -641,9 +763,20 @@ async fn run_one_turn( _ = cancel.notified() => { drop(byte_stream); let _ = app.emit(channel, StreamEvent::Cancelled); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Ok(None); } + _ = tokio::time::sleep_until( + usage_deadline.unwrap_or_else(tokio::time::Instant::now) + ), if usage_deadline.is_some() => { + // Finished, but neither `[DONE]` nor the close followed — + // same fold as the `finished` path below. + if let Some(n) = turn_tokens { + *reported_tokens = + Some(reported_tokens.unwrap_or(0).saturating_add(n)); + } + return Ok(Some(decide_outcome(accum))); + } maybe = byte_stream.next() => { match maybe { Some(Ok(chunk)) => { @@ -658,13 +791,14 @@ async fn run_one_turn( ), }, ); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Err(anyhow!("openai stream frame too large")); } let mut pending_token = String::new(); let mut pending_think = String::new(); let mut finished = false; + let mut stream_error: Option<String> = None; while let Some((rel_pos, sep_len)) = find_frame_end(&buf[scan_offset..]) { @@ -675,7 +809,7 @@ async fn run_one_turn( &frame[..frame.len() - sep_len], ) .to_string(); - for line in text.lines() { + for line in frame_lines(&text) { let line = line.trim_start(); if !line.starts_with("data:") { continue; } let data = line[5..].trim_start(); @@ -696,6 +830,10 @@ async fn run_one_turn( ); } if let Ok(parsed) = parsed { + if let Some(err) = parsed.error.as_ref() { + stream_error = Some(stream_error_message(err)); + break; + } // OpenAI (and most compat servers) // emit a final choices-empty frame // carrying `usage`. When we see it, @@ -723,9 +861,17 @@ async fn run_one_turn( turn_tokens = Some(n); } } + // Past the finish only `usage` still + // counts: the reply's text and tool + // calls are complete. + if finish_seen { continue; } for c in parsed.choices { if let Some(d) = c.delta { - if let Some(think) = d.reasoning_content { + let think = d + .reasoning_content + .filter(|t| !t.is_empty()) + .or(d.reasoning); + if let Some(think) = think { if !think.is_empty() { pending_think.push_str(&think); } @@ -734,28 +880,19 @@ async fn run_one_turn( if !delta.is_empty() { *total_tokens += 1; pending_token.push_str(&delta); + turn_text.push_str(&delta); } } - for tc in d.tool_calls { + for (pos, tc) in d.tool_calls.into_iter().enumerate() { // OpenAI's wire protocol // always includes the // index. A compat server - // that omits it confuses - // multi-call assembly - // (without `index` we - // can't tell which slot - // a delta belongs to) — - // log and skip rather - // than silently collapse - // every call into slot 0. - let Some(idx_raw) = tc.index else { - tracing::warn!( - "openai stream: tool_call delta has no `index`; \ - skipping frame to avoid collapsing into slot 0" - ); - continue; - }; - let idx = idx_raw as usize; + // that omits it sends each + // call whole, so its place + // in this delta's list is + // its slot — skipping them + // dropped every call. + let idx = tc.index.map_or(pos, |i| i as usize); // `index` is untrusted server // input — cap it so a single // frame can't drive `resize_with` @@ -807,7 +944,7 @@ async fn run_one_turn( ), }, ); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Err(anyhow!("openai tool-call payload too large")); } } @@ -818,16 +955,23 @@ async fn run_one_turn( // We still let the current chunk's // deltas accumulate (they may carry // the last tokens or tool-call - // pieces) — the `finished` flag - // only takes effect once the - // outer frame loop finishes. - if c.finish_reason.is_some() { - finished = true; + // pieces). Reading goes on only for + // the `usage` frame, which comes + // after this one. + match c.finish_reason.as_deref() { + Some("error") => { + stream_error = Some( + "the server ended the reply with an error".into(), + ); + } + Some(_) => finish_seen = true, + None => {} } } + if stream_error.is_some() { break; } } } - if finished { break; } + if finished || stream_error.is_some() { break; } } // The longest SSE frame separator is `\r\n\r\n` // (4 bytes), so keep that many bytes of overlap @@ -846,6 +990,18 @@ async fn run_one_turn( StreamEvent::Token { delta: pending_token }, ); } + // The text so far went out above; the reply ends + // here as an error, not Done. + if let Some(message) = stream_error { + let _ = app.emit( + channel, + StreamEvent::Error { + message: format!("OpenAI stream stopped mid-reply: {message}"), + }, + ); + registry.finish_own(stream_id, cancel); + return Err(anyhow!("openai stream error: {message}")); + } if finished { // Fold this turn's reported usage into the // running total exactly once. @@ -855,13 +1011,16 @@ async fn run_one_turn( } return Ok(Some(decide_outcome(accum))); } + if finish_seen && usage_deadline.is_none() { + usage_deadline = Some(tokio::time::Instant::now() + USAGE_GRACE); + } } Some(Err(e)) => { let _ = app.emit( channel, StreamEvent::Error { message: format!("stream error: {e}") }, ); - registry.finish(stream_id); + registry.finish_own(stream_id, cancel); return Err(e.into()); } None => { @@ -974,7 +1133,7 @@ fn parse_args(s: &str) -> Value { /// Anything else — http:// to an external host — is treated as /// untrusted transport and we drop the auth header to avoid leaking the /// user's API key over cleartext. -fn is_safe_for_bearer(base_url: &str) -> bool { +pub(crate) fn is_safe_for_bearer(base_url: &str) -> bool { let Ok(url) = reqwest::Url::parse(base_url) else { return false; }; @@ -1045,6 +1204,68 @@ fn sniff_image_mime(b64: &str) -> &'static str { mod tests { use super::*; + // --- refused_field / apply_refusal ---------------------------------------- + // OpenAI's reasoning models (o-series, GPT-5) refuse `max_tokens` and a + // non-default `temperature`, and every request carries both — so without + // the retry each send to them failed with a 400. + + fn request() -> Value { + json!({ + "model": "gpt-5", + "messages": [], + "stream": true, + "stream_options": { "include_usage": true }, + "temperature": 0.7, + "max_tokens": 4096, + }) + } + + #[test] + fn moves_a_refused_max_tokens_to_max_completion_tokens() { + let mut body = request(); + let err = r#"{"error":{"message":"Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.","type":"invalid_request_error","param":"max_tokens","code":"unsupported_parameter"}}"#; + let field = refused_field(&body, err).expect("named"); + assert_eq!(field, "max_tokens"); + apply_refusal(&mut body, &field); + assert!(body.get("max_tokens").is_none()); + assert_eq!(body["max_completion_tokens"], 4096); + } + + #[test] + fn drops_a_refused_temperature() { + let mut body = request(); + let err = r#"{"error":{"message":"Unsupported value: 'temperature' does not support 0.7 with this model. Only the default (1) value is supported.","type":"invalid_request_error","param":"temperature","code":"unsupported_value"}}"#; + let field = refused_field(&body, err).expect("named"); + apply_refusal(&mut body, &field); + assert!(body.get("temperature").is_none()); + assert_eq!(body["max_tokens"], 4096); + } + + #[test] + fn reads_the_field_from_plain_text_errors_too() { + let body = request(); + assert_eq!( + refused_field(&body, "Unsupported parameter: 'max_tokens' is not supported").as_deref(), + Some("max_tokens") + ); + assert_eq!( + refused_field(&body, "unknown field `stream_options`").as_deref(), + Some("stream_options") + ); + } + + #[test] + fn leaves_other_400s_alone() { + let body = request(); + // The conversation itself, a field we never sent, and prose that + // merely mentions a knob are all reported, not retried. + let messages = r#"{"error":{"message":"This model's maximum context length is 8192 tokens.","param":"messages","code":"context_length_exceeded"}}"#; + assert_eq!(refused_field(&body, messages), None); + let unsent = r#"{"error":{"message":"Unsupported value","param":"top_p"}}"#; + assert_eq!(refused_field(&body, unsent), None); + assert_eq!(refused_field(&body, "temperature must be at most 2"), None); + } + // --- is_safe_for_bearer ------------------------------------------------- // A regression here silently leaks the user's API key over cleartext // HTTP, so pin both directions of the decision table. @@ -1203,4 +1424,66 @@ mod tests { assert!(matches!(decide_outcome(debris), TurnOutcome::Done)); assert!(matches!(decide_outcome(vec![]), TurnOutcome::Done)); } + + // --- SseChunk --------------------------------------------------------------- + + #[test] + fn explicit_null_lists_read_as_empty() { + // Some compat servers send this on every delta; it used to fail the + // frame, so the whole reply came back empty. + let chunk: SseChunk = serde_json::from_str( + r#"{"choices":[{"delta":{"content":"Hi","tool_calls":null},"finish_reason":null}]}"#, + ) + .expect("parses"); + let delta = chunk.choices[0].delta.as_ref().expect("delta"); + assert_eq!(delta.content.as_deref(), Some("Hi")); + assert!(delta.tool_calls.is_empty()); + + let usage_only: SseChunk = + serde_json::from_str(r#"{"choices":null,"usage":{"completion_tokens":7}}"#) + .expect("parses"); + assert!(usage_only.choices.is_empty()); + assert_eq!(usage_only.usage.and_then(|u| u.completion_tokens), Some(7)); + } + + /// Ollama's `/v1`, OpenRouter and newer vLLM name the field `reasoning`; + /// reading only `reasoning_content` dropped their thinking. + #[test] + fn reasoning_is_read_under_either_name() { + let chunk: SseChunk = + serde_json::from_str(r#"{"choices":[{"delta":{"reasoning":"Let me see"}}]}"#) + .expect("parses"); + let delta = chunk.choices[0].delta.as_ref().expect("delta"); + assert_eq!(delta.reasoning.as_deref(), Some("Let me see")); + assert!(delta.reasoning_content.is_none()); + } + + #[test] + fn mid_stream_error_frames_carry_their_message() { + // vLLM / llama-server: an error frame of its own. + let chunk: SseChunk = serde_json::from_str( + r#"{"error":{"message":"CUDA out of memory","type":"server_error","code":500}}"#, + ) + .expect("parses"); + assert_eq!( + chunk.error.as_ref().map(stream_error_message).as_deref(), + Some("CUDA out of memory") + ); + // OpenRouter: the error beside a `finish_reason: "error"` choice. + let chunk: SseChunk = serde_json::from_str( + r#"{"error":{"code":502,"message":"Provider disconnected"},"choices":[{"delta":{"content":""},"finish_reason":"error"}]}"#, + ) + .expect("parses"); + assert_eq!( + chunk.error.as_ref().map(stream_error_message).as_deref(), + Some("Provider disconnected") + ); + assert_eq!(chunk.choices[0].finish_reason.as_deref(), Some("error")); + + assert_eq!(stream_error_message(&json!("overloaded")), "overloaded"); + assert_eq!(stream_error_message(&json!({ "code": 1 })), r#"{"code":1}"#); + // An explicit `"error": null` is no error. + let ok: SseChunk = serde_json::from_str(r#"{"choices":[],"error":null}"#).expect("parses"); + assert!(ok.error.is_none()); + } } diff --git a/src-tauri/src/rag/embed.rs b/src-tauri/src/rag/embed.rs new file mode 100644 index 0000000..588faf6 --- /dev/null +++ b/src-tauri/src/rag/embed.rs @@ -0,0 +1,293 @@ +//! Embedding calls for the retrieval index: Ollama's `/api/embed` or an +//! OpenAI-compatible `/embeddings`, chosen in Settings → Library. +//! +//! Both go through the shared provider client with the same guards the chat +//! path uses: link-local hosts are refused, and the OpenAI key only travels +//! over https or to loopback. + +use std::time::Duration; + +use serde::Deserialize; + +use crate::db::Database; + +/// Mirrors the frontend defaults in `src/types.ts`. +const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434"; +const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; + +/// Long enough for Ollama to load the model on the first batch. +const EMBED_TIMEOUT: Duration = Duration::from_secs(120); +/// A query embedding sits on the send path, so it gets less patience. +const QUERY_TIMEOUT: Duration = Duration::from_secs(20); + +/// Why an embedding request failed. +#[derive(Debug)] +pub struct EmbedError { + pub message: String, + /// The server answered and turned the input down (too long, malformed), + /// as opposed to being unreachable, rate-limiting, overloaded or missing + /// the model — failures that say nothing about the input itself. + pub refused: bool, +} + +impl std::fmt::Display for EmbedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +/// Transport, decoding and setup failures: never about the input. +impl From<String> for EmbedError { + fn from(message: String) -> Self { + Self { message, refused: false } + } +} + +/// Whether an error response means "this input can't be embedded" rather +/// than "the server can't embed right now". 400 / 413 / 422 are the +/// input-is-wrong statuses; llama.cpp-based servers answer an input over +/// their batch size with a 500 that says so, hence the wording check. +pub fn is_refusal(status: u16, message: &str) -> bool { + let m = message.to_ascii_lowercase(); + matches!(status, 400 | 413 | 422) + || (status >= 500 && (m.contains("too large") || m.contains("too long"))) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct EmbedConfig { + /// `"ollama"` or `"openai"`. + pub provider: String, + pub model: String, + pub base_url: String, +} + +impl EmbedConfig { + /// The configured embedding model, or `None` when none is set — the + /// index then stays keyword-only. + pub fn from_db(db: &Database) -> Option<Self> { + let get = |k: &str| db.get_setting(k).ok().flatten().unwrap_or_default(); + let model = get("embedding_model").trim().to_string(); + if model.is_empty() { + return None; + } + let provider = match get("embedding_provider").as_str() { + "openai" => "openai", + _ => "ollama", + }; + let base_url = if provider == "openai" { + Some(get("openai_base_url")).filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_OPENAI_BASE_URL.to_string()) + } else { + Some(get("ollama_base_url")).filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_OLLAMA_BASE_URL.to_string()) + }; + Some(Self { provider: provider.to_string(), model, base_url }) + } + + /// `provider:model`, as the index status names the model. + pub fn model_label(&self) -> String { + format!("{}:{}", self.provider, self.model) + } + + /// What `rag_chunks.embed_model` records, so switching provider, model + /// or server marks every existing vector stale: the same tag on another + /// server can be another build of the model, with vectors of the same + /// length that don't compare. + pub fn model_key(&self) -> String { + format!("{}@{}", self.model_label(), self.base_url.trim_end_matches('/')) + } +} + +/// Formats a chunk (given its document title) for embedding. +type DocFormat = fn(&str, &str) -> String; +/// Formats a search query for embedding. +type QueryFormat = fn(&str) -> String; + +/// Several embedding models are trained with task prefixes and retrieve +/// noticeably worse without them. Returns `(document, query)` formatters. +fn prefixes(model: &str) -> (DocFormat, QueryFormat) { + let m = model.to_ascii_lowercase(); + if m.contains("nomic-embed") { + (|t, x| format!("search_document: {t}\n\n{x}"), |q| format!("search_query: {q}")) + } else if m.contains("embeddinggemma") { + ( + |t, x| format!("title: {} | text: {x}", if t.is_empty() { "none" } else { t }), + |q| format!("task: search result | query: {q}"), + ) + } else if m.contains("mxbai-embed") { + ( + |t, x| format!("{t}\n\n{x}"), + |q| format!("Represent this sentence for searching relevant passages: {q}"), + ) + } else if m.contains("qwen3-embedding") { + ( + |t, x| format!("{t}\n\n{x}"), + |q| format!("Instruct: Given a question, retrieve passages that answer it\nQuery: {q}"), + ) + } else { + (|t, x| format!("{t}\n\n{x}"), |q| q.to_string()) + } +} + +/// Text the indexer embeds for one chunk of the document titled `title`. +pub fn document_input(cfg: &EmbedConfig, title: &str, text: &str) -> String { + (prefixes(&cfg.model).0)(title, text) +} + +/// Embed one search query, unit-length. +pub async fn embed_query(http: &reqwest::Client, cfg: &EmbedConfig, query: &str) -> Result<Vec<f32>, EmbedError> { + let input = (prefixes(&cfg.model).1)(query); + let mut out = embed(http, cfg, &[input], QUERY_TIMEOUT).await?; + out.pop().ok_or_else(|| "the embedding model returned nothing".to_string().into()) +} + +/// Embed a batch of inputs, unit-length, in input order. +pub async fn embed_batch(http: &reqwest::Client, cfg: &EmbedConfig, inputs: &[String]) -> Result<Vec<Vec<f32>>, EmbedError> { + embed(http, cfg, inputs, EMBED_TIMEOUT).await +} + +/// Embed one trivial input: whether the model embeds anything at all, when +/// an input failed on its own or every real input was refused. Background +/// work, so the indexer's patience: after a runner crashed on an input, +/// Ollama reloads the model before answering. +pub async fn probe(http: &reqwest::Client, cfg: &EmbedConfig) -> Result<(), EmbedError> { + embed(http, cfg, &["hello".to_string()], EMBED_TIMEOUT).await.map(|_| ()) +} + +#[derive(Deserialize)] +struct OllamaEmbedResponse { + embeddings: Vec<Vec<f32>>, +} + +#[derive(Deserialize)] +struct OpenAIEmbedResponse { + data: Vec<OpenAIEmbedding>, +} + +#[derive(Deserialize)] +struct OpenAIEmbedding { + index: usize, + embedding: Vec<f32>, +} + +async fn embed( + http: &reqwest::Client, + cfg: &EmbedConfig, + inputs: &[String], + timeout: Duration, +) -> Result<Vec<Vec<f32>>, EmbedError> { + crate::providers::refuse_link_local_host(&cfg.base_url).await?; + let base = cfg.base_url.trim_end_matches('/'); + let vectors = if cfg.provider == "openai" { + let mut req = http + .post(format!("{base}/embeddings")) + .timeout(timeout) + .json(&serde_json::json!({ "model": cfg.model, "input": inputs })); + // Off the runtime — blocking OS-keyring read, as in the chat path. + let key = tokio::task::spawn_blocking(crate::secrets::get_openai_key) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + if let Some(key) = key.filter(|k| !k.is_empty()) { + if crate::providers::openai::is_safe_for_bearer(&cfg.base_url) { + req = req.bearer_auth(key); + } + } + let resp = req.send().await.map_err(|e| e.to_string())?; + let status = resp.status(); + if !status.is_success() { + return Err(error_body(status, resp).await); + } + let mut body: OpenAIEmbedResponse = resp.json().await.map_err(|e| e.to_string())?; + body.data.sort_by_key(|d| d.index); + body.data.into_iter().map(|d| d.embedding).collect::<Vec<_>>() + } else { + let resp = http + .post(format!("{base}/api/embed")) + .timeout(timeout) + .json(&serde_json::json!({ "model": cfg.model, "input": inputs, "truncate": true })) + .send() + .await + .map_err(|e| e.to_string())?; + let status = resp.status(); + if !status.is_success() { + return Err(error_body(status, resp).await); + } + let body: OllamaEmbedResponse = resp.json().await.map_err(|e| e.to_string())?; + body.embeddings + }; + if vectors.len() != inputs.len() { + return Err(format!( + "the embedding model returned {} vectors for {} inputs", + vectors.len(), + inputs.len() + ) + .into()); + } + Ok(vectors.into_iter().map(super::normalize).collect()) +} + +/// A short, readable reason from an error response (`{"error": "..."}` from +/// Ollama, `{"error": {"message": ...}}` from OpenAI-style servers). +async fn error_body(status: reqwest::StatusCode, resp: reqwest::Response) -> EmbedError { + let text = resp.text().await.unwrap_or_default(); + let reason = serde_json::from_str::<serde_json::Value>(&text) + .ok() + .and_then(|v| { + let e = v.get("error")?; + e.as_str() + .map(str::to_string) + .or_else(|| e.get("message").and_then(|m| m.as_str()).map(str::to_string)) + }) + .unwrap_or_else(|| text.chars().take(200).collect()); + let reason = reason.trim(); + EmbedError { + message: format!("HTTP {}: {}", status.as_u16(), reason), + refused: is_refusal(status.as_u16(), reason), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(model: &str) -> EmbedConfig { + EmbedConfig { provider: "ollama".into(), model: model.into(), base_url: String::new() } + } + + #[test] + fn task_prefixes_follow_the_model() { + assert_eq!(document_input(&cfg("nomic-embed-text:latest"), "T", "x"), "search_document: T\n\nx"); + assert_eq!(document_input(&cfg("embeddinggemma"), "", "x"), "title: none | text: x"); + // EmbeddingGemma 2 kept the first one's prompts. + assert_eq!(document_input(&cfg("embeddinggemma-2:270m"), "", "x"), "title: none | text: x"); + assert_eq!(document_input(&cfg("bge-m3"), "T", "x"), "T\n\nx"); + } + + // Regression: the key left out the server, so pointing the same tag at + // another server (another build of the model) mixed old and new vectors. + #[test] + fn the_index_key_names_the_server() { + let at = |url: &str| EmbedConfig { base_url: url.into(), ..cfg("bge-m3") }; + assert_eq!(at("http://localhost:11434").model_key(), "ollama:bge-m3@http://localhost:11434"); + assert_ne!(at("http://localhost:11434").model_key(), at("http://lan:11434").model_key()); + // A trailing slash is the same server. + assert_eq!(at("http://lan:11434/").model_key(), at("http://lan:11434").model_key()); + assert_eq!(at("http://lan:11434").model_label(), "ollama:bge-m3"); + } + + #[test] + fn only_input_errors_count_as_refusals() { + // The input itself is the problem: worth isolating and setting aside. + assert!(is_refusal(400, "input length exceeds the context length")); + assert!(is_refusal(413, "Input validation error: inputs must have less than 512 tokens")); + assert!(is_refusal(422, "invalid input")); + assert!(is_refusal(500, "input is too large to process. increase the physical batch size")); + // The server or model is: retry later, set nothing aside. + assert!(!is_refusal(404, "model 'nomic-embed-text' not found")); + assert!(!is_refusal(401, "invalid api key")); + assert!(!is_refusal(429, "rate limit reached")); + assert!(!is_refusal(500, "llama runner process has terminated")); + assert!(!is_refusal(503, "server overloaded")); + } +} diff --git a/src-tauri/src/rag/indexer.rs b/src-tauri/src/rag/indexer.rs new file mode 100644 index 0000000..10792ec --- /dev/null +++ b/src-tauri/src/rag/indexer.rs @@ -0,0 +1,473 @@ +//! Background worker that keeps the retrieval index complete: it chunks any +//! document that has no chunks yet (rows from before the index existed, or +//! everything after a restore) and embeds every chunk that lacks a vector +//! from the configured model. +//! +//! Woken through [`Indexer::wake`] whenever something could have created work +//! — a source added or edited, the embedding model changed, a restore — and +//! otherwise asleep. Progress is broadcast on [`STATUS_EVENT`] so the Library +//! and Settings can show it. + +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::Mutex; +use serde::Serialize; +use tauri::{AppHandle, Emitter}; +use tokio::sync::Notify; + +use super::embed::{self, EmbedConfig, EmbedError}; +use super::QueryCooldown; +use crate::db::library::ChunkVector; +use crate::db::Database; + +/// Tauri event carrying an [`IndexStatus`] after every state change. +pub const STATUS_EVENT: &str = "library://index"; + +/// Chunks per embedding request. +const BATCH: usize = 16; +/// Wait before retrying after an embedding error, unless woken sooner (the +/// user fixing the model in Settings wakes the worker straight away). +const RETRY_AFTER: Duration = Duration::from_secs(60); + +#[derive(Debug, Serialize, Clone, Default)] +pub struct IndexStatus { + /// Model (`provider:model`, see [`EmbedConfig::model_label`]) chunks are + /// embedded with; `None` when no embedding model is configured and the + /// index is keyword-only. + pub model: Option<String>, + /// The whole [`EmbedConfig::model_key`], server included, behind `model`. + /// Not sent: the frontend reads `model` as `provider:model`. + #[serde(skip)] + pub key: Option<String>, + pub total: i64, + pub embedded: i64, + pub busy: bool, + pub error: Option<String>, +} + +impl IndexStatus { + /// The last error, if it came from embedding with `cfg` — not from the + /// same model on the server the settings named before. Matched by label, + /// the old server's error held for the new one until the indexer's next + /// pass. + pub fn error_for(&self, cfg: &EmbedConfig) -> Option<&str> { + self.error.as_deref().filter(|_| self.key.as_deref() == Some(cfg.model_key().as_str())) + } +} + +pub struct Indexer { + notify: Notify, + status: Mutex<IndexStatus>, + /// The send path's last failed query embedding (see `rag::search`). + pub(super) query_cooldown: Mutex<QueryCooldown>, +} + +impl Indexer { + /// Start the worker and return its handle. + pub fn spawn(app: AppHandle, db: Arc<Database>, http: reqwest::Client) -> Arc<Self> { + let ix = Arc::new(Self { + notify: Notify::new(), + status: Mutex::new(IndexStatus::default()), + query_cooldown: Mutex::new(QueryCooldown::default()), + }); + let worker = ix.clone(); + tauri::async_runtime::spawn(async move { worker.run(app, db, http).await }); + ix + } + + /// Ask the worker to look for work. A wake while it's busy isn't lost: + /// `notify_one` stores a permit the next wait consumes. + pub fn wake(&self) { + self.notify.notify_one(); + } + + pub fn status(&self) -> IndexStatus { + self.status.lock().clone() + } + + fn publish(&self, app: &AppHandle, status: IndexStatus) { + *self.status.lock() = status.clone(); + let _ = app.emit(STATUS_EVENT, status); + } + + async fn run(self: Arc<Self>, app: AppHandle, db: Arc<Database>, http: reqwest::Client) { + // The settings the last pass embedded with. + let mut last = None; + loop { + // Once per wake, not per batch: new sources are chunked as + // they're written, so this only finds rows from before the index + // existed or from a restore. + let d = db.clone(); + if let Err(e) = blocking(move || d.backfill_rag_chunks()).await { + tracing::warn!("rag backfill failed: {e:?}"); + } + match self.embed_pending(&app, &db, &http, &mut last).await { + Outcome::Idle => self.notify.notified().await, + Outcome::Retry => { + tokio::select! { + _ = self.notify.notified() => {} + _ = tokio::time::sleep(RETRY_AFTER) => {} + } + } + } + } + } + + /// Embed batches until nothing is pending, or until something fails and + /// the worker should pause before trying again. + async fn embed_pending( + &self, + app: &AppHandle, + db: &Arc<Database>, + http: &reqwest::Client, + last: &mut Option<EmbedConfig>, + ) -> Outcome { + loop { + let d = db.clone(); + let next = blocking(move || { + let cfg = EmbedConfig::from_db(&d); + let key = cfg.as_ref().map(EmbedConfig::model_key); + let (total, embedded) = d.rag_counts(key.as_deref())?; + let pending = match &key { + Some(k) => d.rag_pending_embeddings(k, BATCH)?, + None => Vec::new(), + }; + Ok((cfg, total, embedded, pending)) + }) + .await; + let (cfg, total, embedded, pending) = match next { + Ok(n) => n, + Err(e) => { + tracing::warn!("rag pending query failed: {e:?}"); + return Outcome::Retry; + } + }; + let Some(cfg) = cfg else { + self.publish(app, IndexStatus { model: None, key: None, total, embedded, busy: false, error: None }); + return Outcome::Idle; + }; + let (key, label) = (cfg.model_key(), cfg.model_label()); + // Chunks are set aside for what one server made of them, so new + // settings give them another try. New settings mostly mean a new + // key, under which every chunk is pending anyway; this covers + // settings that come back to an earlier key. + if last.replace(cfg.clone()).is_some_and(|was| was != cfg) { + let (d, k) = (db.clone(), key.clone()); + if let Err(e) = blocking(move || d.retry_skipped_rag_chunks(&k)).await { + tracing::warn!("rag retry of set-aside chunks failed: {e:?}"); + } + continue; + } + if pending.is_empty() { + self.publish(app, IndexStatus { model: Some(label), key: Some(key), total, embedded, busy: false, error: None }); + return Outcome::Idle; + } + + self.publish(app, IndexStatus { model: Some(label), key: Some(key.clone()), total, embedded, busy: true, error: None }); + let inputs: Vec<String> = pending + .iter() + .map(|c| embed::document_input(&cfg, &c.title, &c.text)) + .collect(); + let Some(result) = self.embed_unless_reconfigured(db, http, &cfg, &inputs).await else { + // The settings changed mid-request: start over on the new ones. + continue; + }; + let ids: Vec<i64> = pending.iter().map(|c| c.id).collect(); + let done = match result { + Ok(vectors) => Isolated { rows: ids.into_iter().zip(vectors).collect(), ..Isolated::default() }, + // One input the server can't take fails the whole request, and + // the same batch would come back every time. + Err(e) => { + let cfg = &cfg; + isolate_failures( + &ids, + &inputs, + e, + move |input| async move { embed::embed_batch(http, cfg, std::slice::from_ref(&input)).await }, + move || embed::probe(http, cfg), + ) + .await + } + }; + if !done.set_aside.is_empty() { + tracing::warn!("{key} couldn't embed {} chunk(s) on their own, left keyword-only", done.set_aside.len()); + } + let Isolated { rows, set_aside, failure } = done; + let (d, k) = (db.clone(), key); + let stored = blocking(move || { + d.store_rag_embeddings(&k, &rows)?; + d.skip_rag_chunks(&k, &set_aside) + }) + .await; + if let Err(e) = stored { + // Without a pause the same batch would be re-embedded at once, + // over and over. + return self.fail(app, &cfg, total, embedded, format!("couldn't save embeddings: {e}")); + } + if let Some(e) = failure { + return self.fail(app, &cfg, total, embedded, e); + } + } + } + + /// Embed `inputs`, unless the embedding settings change meanwhile — then + /// `None`, so the pass restarts on the new settings instead of waiting out + /// a request to a server the user just replaced (up to the 120 s embed + /// timeout). A wake for anything else is handed on to the run loop. + async fn embed_unless_reconfigured( + &self, + db: &Arc<Database>, + http: &reqwest::Client, + cfg: &EmbedConfig, + inputs: &[String], + ) -> Option<Result<Vec<Vec<f32>>, EmbedError>> { + let request = embed::embed_batch(http, cfg, inputs); + tokio::pin!(request); + let mut woken = false; + let out = loop { + tokio::select! { + r = &mut request => break Some(r), + _ = self.notify.notified() => { + woken = true; + let d = db.clone(); + let now = blocking(move || Ok(EmbedConfig::from_db(&d))).await.ok().flatten(); + if now.as_ref() != Some(cfg) { + break None; + } + } + } + }; + if woken { + // The run loop backfills once per wake (a restore relies on it). + self.notify.notify_one(); + } + out + } + + fn fail(&self, app: &AppHandle, cfg: &EmbedConfig, total: i64, embedded: i64, error: String) -> Outcome { + let label = cfg.model_label(); + tracing::warn!("embedding with {label} failed: {error}"); + let key = Some(cfg.model_key()); + self.publish(app, IndexStatus { model: Some(label), key, total, embedded, busy: false, error: Some(error) }); + Outcome::Retry + } +} + +/// What came of a batch: vectors to store, chunks to set aside, and whether +/// the pass has to stop and retry later. +#[derive(Debug, Default)] +struct Isolated { + rows: Vec<ChunkVector>, + set_aside: Vec<i64>, + failure: Option<String>, +} + +/// The server turned a batch down (`batch`). Ask for each input on its own +/// (`embed_one`), so only the inputs that fail on their own are set aside +/// and the rest still get vectors. A refusal sets its input aside at once; +/// any other failure (a runner that crashes on that input, a vector missing +/// from the answer) only if `probe`, a trivial input sent right after, goes +/// through. If that fails too, the server went away or started +/// rate-limiting: stop, keeping the vectors that went through and setting +/// nothing aside, so an outage never marks a chunk done. When nothing went +/// through, `probe` also tells a model that can't embed at all (fail and +/// retry) from inputs it can't take (set aside). +async fn isolate_failures<E, EF, P, PF>( + ids: &[i64], + inputs: &[String], + batch: EmbedError, + mut embed_one: E, + mut probe: P, +) -> Isolated +where + E: FnMut(String) -> EF, + EF: Future<Output = Result<Vec<Vec<f32>>, EmbedError>>, + P: FnMut() -> PF, + PF: Future<Output = Result<(), EmbedError>>, +{ + let mut out = Isolated::default(); + // Whether the model embedded anything, a probe included. + let mut works = false; + // A batch of one was this input on its own: how it fails is known. + let mut known = (ids.len() == 1).then_some(batch); + for (&id, input) in ids.iter().zip(inputs) { + let result = match known.take() { + Some(e) => Err(e), + None => embed_one(input.clone()).await, + }; + match result { + Ok(mut v) => { + works = true; + out.rows.extend(v.pop().map(|v| (id, v))); + } + Err(e) if e.refused => out.set_aside.push(id), + Err(e) => match probe().await { + Ok(()) => { + works = true; + out.set_aside.push(id); + } + Err(_) => { + out.set_aside.clear(); + out.failure = Some(e.message); + return out; + } + }, + } + } + if !works { + if let Err(e) = probe().await { + out.set_aside.clear(); + out.failure = Some(e.message); + } + } + out +} + +/// How a pass of [`Indexer::embed_pending`] ended. +enum Outcome { + /// Nothing left to do; sleep until woken. + Idle, + /// Something failed; try again after [`RETRY_AFTER`] or a wake. + Retry, +} + +/// Run a blocking database call off the async runtime. +async fn blocking<T, F>(f: F) -> anyhow::Result<T> +where + F: FnOnce() -> anyhow::Result<T> + Send + 'static, + T: Send + 'static, +{ + tokio::task::spawn_blocking(f).await? +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + + fn ok() -> Result<Vec<Vec<f32>>, EmbedError> { + Ok(vec![vec![1.0]]) + } + fn refused() -> Result<Vec<Vec<f32>>, EmbedError> { + Err(EmbedError { message: "HTTP 400: too long".into(), refused: true }) + } + fn outage() -> Result<Vec<Vec<f32>>, EmbedError> { + Err(EmbedError { message: "HTTP 429: rate limit".into(), refused: false }) + } + fn crash() -> Result<Vec<Vec<f32>>, EmbedError> { + Err(EmbedError { message: "HTTP 500: llama runner process has terminated".into(), refused: false }) + } + + // Regression: matched by `provider:model` alone, the old server's error + // kept searches on the new one from embedding their query. + #[test] + fn an_error_holds_only_for_the_server_it_came_from() { + let cfg = |url: &str| EmbedConfig { provider: "ollama".into(), model: "m".into(), base_url: url.into() }; + let (old, new) = (cfg("http://lan:11434"), cfg("http://localhost:11434")); + let status = IndexStatus { + model: Some(old.model_label()), + key: Some(old.model_key()), + error: Some("connection refused".into()), + ..IndexStatus::default() + }; + assert_eq!(status.error_for(&old), Some("connection refused")); + assert_eq!(status.error_for(&new), None); + // The frontend is sent `model` as before, and not the key. + let sent = serde_json::to_value(&status).unwrap(); + assert_eq!(sent["model"], "ollama:m"); + assert!(sent.get("key").is_none(), "{sent}"); + } + + /// Run [`isolate_failures`] over `ids` after the batch failed with + /// `batch`, with scripted per-input answers and probe result; also + /// returns how many single requests were made. + async fn run( + ids: &[i64], + batch: Result<Vec<Vec<f32>>, EmbedError>, + answers: Vec<Result<Vec<Vec<f32>>, EmbedError>>, + probe_ok: bool, + ) -> (Isolated, usize) { + let inputs: Vec<String> = ids.iter().map(|i| format!("chunk {i}")).collect(); + let mut script = VecDeque::from(answers); + let mut calls = 0; + let out = isolate_failures( + ids, + &inputs, + batch.unwrap_err(), + |_input| { + calls += 1; + let r = script.pop_front().expect("more requests than scripted answers"); + async move { r } + }, + || async move { + if probe_ok { + Ok(()) + } else { + Err(EmbedError { message: "HTTP 400: model does not support embeddings".into(), refused: true }) + } + }, + ) + .await; + (out, calls) + } + + #[tokio::test] + async fn only_the_refused_inputs_are_set_aside() { + let (out, _) = run(&[1, 2, 3], refused(), vec![ok(), refused(), ok()], true).await; + assert_eq!(out.rows.iter().map(|r| r.0).collect::<Vec<_>>(), vec![1, 3]); + assert_eq!(out.set_aside, vec![2]); + assert!(out.failure.is_none()); + } + + #[tokio::test] + async fn an_outage_mid_way_sets_nothing_aside_and_stops_asking() { + // Regression: any error on a single request used to mark that chunk + // done for good, so a burst of 429s emptied the vector index while + // the status read 100%. + let (out, calls) = run(&[1, 2, 3, 4], refused(), vec![ok(), refused(), outage()], false).await; + assert_eq!(calls, 3, "no requests after the outage"); + assert_eq!(out.rows.iter().map(|r| r.0).collect::<Vec<_>>(), vec![1], "what went through is kept"); + assert!(out.set_aside.is_empty()); + assert_eq!(out.failure.as_deref(), Some("HTTP 429: rate limit")); + } + + #[tokio::test] + async fn an_input_that_fails_on_its_own_is_set_aside() { + // Regression: only refusals were isolated, so an input that crashed + // the runner failed its batch every minute forever, and nothing + // after it was ever embedded. + let (out, calls) = run(&[1, 2, 3], crash(), vec![ok(), crash(), ok()], true).await; + assert_eq!(calls, 3); + assert_eq!(out.rows.iter().map(|r| r.0).collect::<Vec<_>>(), vec![1, 3]); + assert_eq!(out.set_aside, vec![2]); + assert!(out.failure.is_none()); + // A batch of one is the same case, without asking twice. + let (out, calls) = run(&[7], crash(), vec![], true).await; + assert_eq!((out.set_aside, calls), (vec![7], 0)); + // Unless the server can't embed anything either: then it's down. + let (out, _) = run(&[7], crash(), vec![], false).await; + assert!(out.set_aside.is_empty()); + assert_eq!(out.failure.as_deref(), Some("HTTP 500: llama runner process has terminated")); + } + + #[tokio::test] + async fn inputs_all_refused_by_a_working_model_are_set_aside() { + // Regression: a batch whose every input was too long for the server + // was retried every minute forever, holding up everything after it. + let (out, _) = run(&[1, 2], refused(), vec![refused(), refused()], true).await; + assert_eq!(out.set_aside, vec![1, 2]); + assert!(out.failure.is_none()); + // A batch of one is the same case, without asking twice. + let (out, calls) = run(&[7], refused(), vec![], true).await; + assert_eq!((out.set_aside, calls), (vec![7], 0)); + } + + #[tokio::test] + async fn a_model_that_embeds_nothing_fails_instead() { + let (out, _) = run(&[1, 2], refused(), vec![refused(), refused()], false).await; + assert!(out.set_aside.is_empty(), "a chat model picked as embedder must not mark the index done"); + assert_eq!(out.failure.as_deref(), Some("HTTP 400: model does not support embeddings")); + } +} diff --git a/src-tauri/src/rag/mod.rs b/src-tauri/src/rag/mod.rs new file mode 100644 index 0000000..a7094a7 --- /dev/null +++ b/src-tauri/src/rag/mod.rs @@ -0,0 +1,611 @@ +//! Retrieval over Library items and Space sources. +//! +//! * **Chunking** ([`chunk_text`]) — documents are cut into ~1,200-character +//! pieces along paragraph, line and sentence boundaries, with a short +//! overlap so a fact straddling a cut is still whole in one chunk. +//! * **Keyword index** — every chunk lands in an FTS5 table as it's written +//! (see `db::library`), so retrieval works before, or without, an +//! embedding model. +//! * **Embeddings** — the background [`indexer`] embeds chunks through Ollama +//! or an OpenAI-compatible endpoint once a model is configured. +//! * **Search** ([`search`]) — BM25 and cosine rankings fused with +//! reciprocal-rank fusion, scoped to the sources a chat actually has, then +//! cut to a character budget. +//! +//! Vectors are compared by brute force in Rust. A personal library is +//! thousands of chunks, not millions, and scoping to one chat's sources +//! keeps each search to a fraction of that — well inside a few +//! milliseconds, without shipping a native vector extension on three +//! platforms. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +use crate::db::library::{ChunkRow, ChunkVector}; +use crate::db::Database; + +pub mod embed; +pub mod indexer; + +/// Characters a chunk aims for. Roughly 300 tokens: small enough that a +/// handful of excerpts fit an 8k context, big enough to carry a whole +/// paragraph of meaning. +pub const CHUNK_TARGET: usize = 1200; +/// Characters carried over from the end of one chunk into the next. +const CHUNK_OVERLAP: usize = 200; + +/// Boundaries tried, in order, when a paragraph is longer than a chunk. +/// Each keeps its separator attached to the piece before it. Chinese and +/// Japanese end sentences and clauses with full-width marks and no space; +/// without them a long CJK paragraph was hard-cut mid-sentence. +const SEPARATORS: [&str; 12] = [ + "\n", ". ", "? ", "! ", "。", "?", "!", "; ", ";", ", ", ",", " ", +]; + +struct Piece { + text: String, + /// Starts a new paragraph, so it joins the previous piece with a blank + /// line rather than directly. + para_start: bool, +} + +fn char_len(s: &str) -> usize { + s.chars().count() +} + +/// Cut `text` into retrieval chunks. Whitespace-only input yields none. +pub fn chunk_text(text: &str) -> Vec<String> { + let text = text.replace("\r\n", "\n"); + let mut pieces = Vec::new(); + for para in text.split("\n\n") { + let para = para.trim(); + if para.is_empty() { + continue; + } + let start = pieces.len(); + split_to_fit(para, &SEPARATORS, &mut pieces); + if let Some(first) = pieces.get_mut(start) { + first.para_start = true; + } + } + pack(pieces) +} + +/// Split `s` into pieces no longer than [`CHUNK_TARGET`], trying each +/// separator in turn and hard-cutting only when none is left. +fn split_to_fit(s: &str, seps: &[&str], out: &mut Vec<Piece>) { + if char_len(s) <= CHUNK_TARGET { + out.push(Piece { text: s.to_string(), para_start: false }); + return; + } + let Some((sep, rest)) = seps.split_first() else { + let chars: Vec<char> = s.chars().collect(); + for window in chars.chunks(CHUNK_TARGET) { + out.push(Piece { text: window.iter().collect(), para_start: false }); + } + return; + }; + let parts: Vec<&str> = s.split_inclusive(sep).collect(); + if parts.len() == 1 { + split_to_fit(s, rest, out); + return; + } + for part in parts { + split_to_fit(part, rest, out); + } +} + +/// Greedily pack pieces into chunks of up to [`CHUNK_TARGET`] characters, +/// seeding each new chunk with the tail of the previous one. +fn pack(pieces: Vec<Piece>) -> Vec<String> { + let mut chunks = Vec::new(); + let mut cur = String::new(); + // Whether `cur` holds anything beyond the overlap carried over from the + // previous chunk. One that doesn't would only repeat the end of the chunk + // before it (a whitespace-only line followed by a long one used to emit + // exactly that), so it's never emitted. + let mut fresh = false; + for piece in pieces { + let joiner = if !cur.is_empty() && piece.para_start { "\n\n" } else { "" }; + if !cur.is_empty() && char_len(&cur) + joiner.len() + char_len(&piece.text) > CHUNK_TARGET { + let tail = overlap_tail(&cur); + if fresh { + chunks.push(cur.trim().to_string()); + } + cur = if char_len(&tail) + 2 + char_len(&piece.text) <= CHUNK_TARGET { + tail + } else { + String::new() + }; + fresh = false; + } + if !cur.is_empty() && piece.para_start { + cur.push_str("\n\n"); + } + cur.push_str(&piece.text); + fresh |= !piece.text.trim().is_empty(); + } + if fresh { + chunks.push(cur.trim().to_string()); + } + chunks +} + +/// The last ~[`CHUNK_OVERLAP`] characters of `s`, starting on a word. +fn overlap_tail(s: &str) -> String { + let n = char_len(s); + if n <= CHUNK_OVERLAP { + return s.to_string(); + } + let tail: String = s.chars().skip(n - CHUNK_OVERLAP).collect(); + match tail.find(char::is_whitespace) { + Some(at) => tail[at..].trim_start().to_string(), + None => tail, + } +} + +/// Little-endian f32 bytes, the `rag_chunks.embedding` format. +pub fn vector_to_blob(v: &[f32]) -> Vec<u8> { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +pub fn blob_to_vector(b: &[u8]) -> Vec<f32> { + b.chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() +} + +/// Scale to unit length so cosine similarity is a plain dot product. A zero +/// vector comes back unchanged. +pub fn normalize(mut v: Vec<f32>) -> Vec<f32> { + let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt(); + if norm > 0.0 { + v.iter_mut().for_each(|x| *x /= norm); + } + v +} + +fn dot(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).map(|(x, y)| x * y).sum() +} + +/// Turn free text into an FTS5 query: its distinct words, each quoted (so +/// FTS5 operators and punctuation in the input are inert), OR-ed together +/// and left to BM25 to rank. `None` when there's no word to search for. +pub fn build_fts_query(query: &str) -> Option<String> { + let mut seen = std::collections::HashSet::new(); + let terms: Vec<String> = query + .split(|c: char| !c.is_alphanumeric()) + .filter(|w| w.chars().count() >= 2) + .map(|w| w.to_lowercase()) + .filter(|w| seen.insert(w.clone())) + .take(32) + .map(|w| format!("\"{w}\"")) + .collect(); + if terms.is_empty() { + None + } else { + Some(terms.join(" OR ")) + } +} + +/// Reciprocal-rank fusion: each list contributes `1 / (K + rank)` per id, so +/// an id ranked well by both keyword and vector search beats one that only +/// one of them likes, without having to calibrate BM25 against cosine. +pub fn rrf(lists: &[Vec<i64>]) -> Vec<(i64, f32)> { + const K: f32 = 60.0; + let mut scores: HashMap<i64, f32> = HashMap::new(); + for list in lists { + for (rank, id) in list.iter().enumerate() { + *scores.entry(*id).or_default() += 1.0 / (K + rank as f32 + 1.0); + } + } + let mut out: Vec<(i64, f32)> = scores.into_iter().collect(); + out.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); + out +} + +/// A document a search may draw from. +#[derive(Debug, Deserialize, Clone)] +pub struct DocRef { + /// `"library"` or `"space_file"`. + pub kind: String, + pub id: String, +} + +#[derive(Debug, Deserialize)] +pub struct SearchArgs { + pub docs: Vec<DocRef>, + pub query: String, + /// Character budget for the returned excerpts. + pub max_chars: usize, + #[serde(default)] + pub max_chunks: Option<usize>, +} + +#[derive(Debug, Serialize, Clone)] +pub struct SearchHit { + pub doc_kind: String, + pub doc_id: String, + pub seq: i64, + pub text: String, + pub score: f32, +} + +#[derive(Debug, Serialize)] +pub struct SearchResult { + pub hits: Vec<SearchHit>, + /// `"hybrid"` (keyword + embeddings), `"keyword"` or `"overview"` (no + /// match, so the documents' opening chunks). + pub method: &'static str, + /// Something the user should know about the quality of this search — + /// the embedding model failing, or the index still being built. + pub warning: Option<String>, +} + +const CANDIDATES: usize = 60; +const DEFAULT_MAX_CHUNKS: usize = 12; +/// How long sends go straight to keyword search after a query embedding +/// failed, rather than each waiting out the same timeout first. +const QUERY_COOLDOWN: Duration = Duration::from_secs(60); + +/// The last query embedding that failed for the server's sake (unreachable, +/// hung, rate-limiting, the model missing), with the settings it was sent +/// with. +#[derive(Debug, Default)] +pub struct QueryCooldown(Option<(embed::EmbedConfig, Instant, String)>); + +impl QueryCooldown { + /// The failure to report instead of embedding a query with `cfg` at + /// `now`: one with the same settings less than [`QUERY_COOLDOWN`] ago. + /// New settings are tried straight away. + pub fn active(&self, cfg: &embed::EmbedConfig, now: Instant) -> Option<&str> { + let (sent, at, error) = self.0.as_ref()?; + (sent == cfg && now.duration_since(*at) < QUERY_COOLDOWN).then_some(error.as_str()) + } + + /// Note how a query embedding with `cfg` ended at `at`. A success clears + /// the record, and so does a refusal: that's about the query, not the + /// server. + pub fn record(&mut self, cfg: &embed::EmbedConfig, at: Instant, error: Option<&embed::EmbedError>) { + self.0 = error.filter(|e| !e.refused).map(|e| (cfg.clone(), at, e.message.clone())); + } +} + +/// What [`fuse`] made of a search's keyword and vector rankings. +#[derive(Debug)] +struct Fused { + /// Chunk ids with their fused score, best first. + ranked: Vec<(i64, f32)>, + /// Whether the ranking by meaning contributed anything. + used_vectors: bool, + /// Vectors of another length than the query, left out. + stale: usize, +} + +/// Fuse the keyword ranking (`keyword`, best first) with the [`CANDIDATES`] +/// `vectors` closest to the query embedding `query`. Both are unit-length, +/// so closeness is a dot product; equal ones rank by id, as in [`rrf`]. A +/// zero vector points nowhere, so it ranks nowhere — and a zero or empty +/// (not embedded) query ranks no vector at all. Vectors of another length +/// than the query came from another build of the model and can't be +/// compared: they're left out and counted as `stale`. +fn fuse(keyword: Vec<i64>, query: &[f32], vectors: &[ChunkVector]) -> Fused { + let directed = |v: &[f32]| v.iter().any(|x| *x != 0.0); + let mut scored = Vec::new(); + let mut stale = 0; + if directed(query) { + for (id, v) in vectors { + if v.len() != query.len() { + stale += 1; + } else if directed(v) { + scored.push((*id, dot(query, v))); + } + } + } + scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); + let semantic: Vec<i64> = scored.into_iter().take(CANDIDATES).map(|(id, _)| id).collect(); + Fused { used_vectors: !semantic.is_empty(), ranked: rrf(&[keyword, semantic]), stale } +} + +/// Search `args.docs` for excerpts relevant to `args.query`. +pub async fn search( + db: Arc<Database>, + http: &reqwest::Client, + indexer: &indexer::Indexer, + args: SearchArgs, +) -> Result<SearchResult, String> { + let keys: Vec<String> = args.docs.iter().map(|d| format!("{}:{}", d.kind, d.id)).collect(); + let scope = serde_json::to_string(&keys).map_err(|e| e.to_string())?; + + let keyword = match build_fts_query(&args.query) { + Some(q) => { + let (db, scope) = (db.clone(), scope.clone()); + tokio::task::spawn_blocking(move || db.rag_keyword_search(&scope, &q, CANDIDATES)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())? + } + None => Vec::new(), + }; + + let mut warning = None; + // The query's embedding — left empty when the search goes by keyword + // alone, which ranks nothing by meaning (see `fuse`) — and the vectors in + // scope to compare it with. + let (mut query_vector, mut vectors, mut total) = (Vec::new(), Vec::new(), 0); + // A message that's only an attachment searches for "": the vectors + // closest to nothing are arbitrary, and OpenAI-compatible servers refuse + // empty input, so that's left to the overview below. + let cfg = if args.query.trim().is_empty() { None } else { embed::EmbedConfig::from_db(&db) }; + if let Some(cfg) = cfg { + let (db2, scope2, key) = (db.clone(), scope.clone(), cfg.model_key()); + let pending; + (vectors, total, pending) = tokio::task::spawn_blocking(move || db2.rag_vectors(&scope2, &key)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + // Chunks the model refused stay unembedded for good, so the warning + // follows what the indexer still has to do. + if pending > 0 { + warning = Some(format!( + "Still indexing — {} of {} excerpts searchable by meaning, the rest by keyword.", + vectors.len(), + total + )); + } + // The query embedding sits on the send path, so skip it when it can't + // pay off: the indexer's last request to this model failed, or the + // last query embedding with these settings did a moment ago (the + // server is down or the model missing — don't hold every send on + // another try; the indexer retries on its own schedule), or nothing + // in scope has a vector to compare against yet. + let status = indexer.status(); + let failing = status + .error_for(&cfg) + .map(str::to_string) + .or_else(|| indexer.query_cooldown.lock().active(&cfg, Instant::now()).map(str::to_string)); + if let Some(e) = failing { + warning = Some(format!("Embedding model unavailable ({e}); searched by keyword only.")); + } else if !vectors.is_empty() { + let result = embed::embed_query(http, &cfg, &args.query).await; + indexer.query_cooldown.lock().record(&cfg, Instant::now(), result.as_ref().err()); + match result { + Ok(q) => query_vector = q, + // This query, not the model: too long for the server, say. + Err(e) if e.refused => { + warning = Some(format!( + "The embedding model turned this message down ({e}); searched by keyword only." + )); + } + Err(e) => { + warning = Some(format!("Embedding model unavailable ({e}); searched by keyword only.")); + } + } + } + } + + let fused = fuse(keyword, &query_vector, &vectors); + // Vectors of another length came from a different model under the same + // key (a re-pulled tag): they can't be compared, so say how to fix it + // rather than quietly searching by keyword. + if fused.stale > 0 { + warning = Some(format!( + "{} of {total} excerpts were embedded by a different version of the model — \ + rebuild the index in Settings → Library to search them by meaning.", + fused.stale + )); + } + let mut method = if fused.used_vectors { "hybrid" } else { "keyword" }; + let mut ranked = fused.ranked; + if ranked.is_empty() { + method = "overview"; + let (db2, scope2) = (db.clone(), scope.clone()); + let lead = tokio::task::spawn_blocking(move || db2.rag_leading_chunks(&scope2, 3)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + ranked = lead.into_iter().map(|id| (id, 0.0)).collect(); + } + + let max_chunks = args.max_chunks.unwrap_or(DEFAULT_MAX_CHUNKS); + let candidates: Vec<(i64, f32)> = ranked.into_iter().take(max_chunks * 3).collect(); + let ids: Vec<i64> = candidates.iter().map(|(id, _)| *id).collect(); + let rows: Vec<ChunkRow> = tokio::task::spawn_blocking(move || db.rag_chunks_by_ids(&ids, &scope)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let score_of: HashMap<i64, f32> = candidates.into_iter().collect(); + + let mut hits = Vec::new(); + let mut used = 0usize; + for row in rows { + let len = char_len(&row.text); + if used + len > args.max_chars { + // Keep looking: a shorter chunk further down may still fit. + continue; + } + used += len; + hits.push(SearchHit { + score: score_of.get(&row.id).copied().unwrap_or(0.0), + doc_kind: row.doc_kind, + doc_id: row.doc_id, + seq: row.seq, + text: row.text, + }); + if hits.len() >= max_chunks { + break; + } + } + Ok(SearchResult { hits, method, warning }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cjk_paragraphs_split_at_sentence_ends() { + let sentence = "机器学习是人工智能的一个分支,它让计算机从数据中学习。"; + let text = sentence.repeat(80); // one paragraph, no spaces + let chunks = chunk_text(&text); + assert!(chunks.len() >= 2); + for c in &chunks { + assert!(char_len(c) <= CHUNK_TARGET, "chunk of {} chars", char_len(c)); + assert!(c.ends_with('。'), "cut mid-sentence: …{}", c.chars().rev().take(8).collect::<String>()); + } + } + + #[test] + fn short_text_is_one_chunk() { + assert_eq!(chunk_text("hello world"), vec!["hello world"]); + assert!(chunk_text(" \n\n \r\n ").is_empty()); + } + + #[test] + fn chunks_respect_target_and_keep_paragraphs() { + let para = "word ".repeat(150); // 750 chars + let text = format!("{para}\n\n{para}\n\n{para}"); + let chunks = chunk_text(&text); + assert!(chunks.len() >= 2); + for c in &chunks { + assert!(char_len(c) <= CHUNK_TARGET, "chunk of {} chars", char_len(c)); + } + } + + #[test] + fn long_paragraph_splits_on_sentences_with_overlap() { + let sentence = "The quick brown fox jumps over the lazy dog. "; + let text = sentence.repeat(80); // ~3600 chars, one paragraph + let chunks = chunk_text(&text); + assert!(chunks.len() >= 3); + for c in &chunks { + assert!(char_len(c) <= CHUNK_TARGET); + } + // The overlap carries the end of one chunk into the next. + let tail: String = chunks[0].chars().rev().take(30).collect::<Vec<_>>().into_iter().rev().collect(); + assert!(chunks[1].contains(tail.trim()), "no overlap between chunks"); + } + + #[test] + fn no_chunk_is_only_the_previous_ones_tail() { + // Regression: a near-full line, a whitespace-only line, then a long + // unbroken one emitted a chunk made of nothing but the first chunk's + // overlap — the same text twice, spending excerpt budget. + let text = format!("{}wo\n \n{}", "word ".repeat(239), "y".repeat(1100)); + let chunks = chunk_text(&text); + assert_eq!(chunks.len(), 2, "{chunks:?}"); + for pair in chunks.windows(2) { + assert!(!pair[0].contains(pair[1].as_str()), "chunk repeats its predecessor: {:?}", pair[1]); + } + assert!(chunks[1].starts_with('y')); + } + + #[test] + fn unbroken_text_is_hard_cut_on_char_boundaries() { + let text = "ż".repeat(3000); + let chunks = chunk_text(&text); + assert!(chunks.len() >= 3); + for c in &chunks { + assert!(char_len(c) <= CHUNK_TARGET); + } + } + + #[test] + fn fts_query_quotes_and_dedupes_terms() { + assert_eq!( + build_fts_query("What's the \"budget\" for Q3? budget!").as_deref(), + Some("\"what\" OR \"the\" OR \"budget\" OR \"for\" OR \"q3\"") + ); + assert_eq!(build_fts_query("? ! a"), None); + // FTS5 syntax in the input is inert once quoted. + assert_eq!(build_fts_query("NEAR(x y) OR -z").as_deref(), Some("\"near\" OR \"or\"")); + } + + #[test] + fn rrf_rewards_agreement() { + let fused = rrf(&[vec![1, 2, 3], vec![3, 4]]); + assert_eq!(fused[0].0, 3, "id ranked by both lists wins"); + assert_eq!(fused.len(), 4); + } + + fn ids(f: &Fused) -> Vec<i64> { + f.ranked.iter().map(|r| r.0).collect() + } + + #[test] + fn fusion_keeps_hits_only_one_ranking_found() { + // 10 has no vector, 12 no keyword match: both still rank, below the + // one both found. + let f = fuse(vec![10, 11], &[1.0, 0.0], &[(11, vec![1.0, 0.0]), (12, vec![0.6, 0.8])]); + assert_eq!(ids(&f), vec![11, 10, 12]); + assert!(f.used_vectors); + assert_eq!(f.stale, 0); + } + + #[test] + fn fusion_breaks_ties_by_id() { + let f = fuse(vec![], &[1.0, 0.0], &[(7, vec![0.6, 0.8]), (3, vec![0.6, 0.8]), (5, vec![1.0, 0.0])]); + assert_eq!(ids(&f), vec![5, 3, 7]); + // The cut to the closest candidates too, whatever order rows came in. + let n = CANDIDATES as i64; + let same: Vec<ChunkVector> = (0..n + 5).rev().map(|id| (id, vec![1.0, 0.0])).collect(); + assert_eq!(ids(&fuse(vec![], &[1.0, 0.0], &same)), (0..n).collect::<Vec<_>>()); + } + + #[test] + fn fusion_leaves_out_vectors_it_cant_compare() { + // Another length: another build of the model, counted as stale. + let f = fuse(vec![], &[1.0, 0.0], &[(1, vec![1.0, 0.0, 0.0]), (2, vec![0.0, 1.0])]); + assert_eq!((ids(&f), f.stale), (vec![2], 1)); + let f = fuse(vec![9], &[1.0, 0.0], &[(1, vec![1.0, 0.0, 0.0])]); + assert_eq!((ids(&f), f.used_vectors, f.stale), (vec![9], false, 1)); + // A zero vector has no direction to be close to. + let f = fuse(vec![], &[1.0, 0.0], &[(1, vec![0.0, 0.0]), (2, vec![-1.0, 0.0])]); + assert_eq!(ids(&f), vec![2]); + // Regression: a query embedding with no direction made every chunk + // equally "close", fusing in arbitrary ones. With none, or no query + // embedding at all, the keyword ranking stands alone. + for q in [&[0.0, 0.0][..], &[]] { + let f = fuse(vec![4], q, &[(1, vec![1.0, 0.0]), (2, vec![1.0, 0.0, 0.0])]); + assert_eq!((ids(&f), f.used_vectors, f.stale), (vec![4], false, 0)); + } + } + + // Regression: a fully embedded index publishes no error, so with the + // embedding server unreachable every send waited out the connect or + // query timeout before falling back to keyword search. + #[test] + fn a_failed_query_embedding_holds_off_the_next_for_a_while() { + let cfg = |url: &str| embed::EmbedConfig { provider: "ollama".into(), model: "m".into(), base_url: url.into() }; + let (lan, local) = (cfg("http://lan:11434"), cfg("http://localhost:11434")); + let down = embed::EmbedError { message: "error sending request".into(), refused: false }; + let t0 = Instant::now(); + let mut c = QueryCooldown::default(); + assert_eq!(c.active(&lan, t0), None); + + c.record(&lan, t0, Some(&down)); + assert_eq!(c.active(&lan, t0 + Duration::from_secs(30)), Some("error sending request")); + // New settings are tried at once; the same ones again after a while. + assert_eq!(c.active(&local, t0 + Duration::from_secs(30)), None); + assert_eq!(c.active(&lan, t0 + QUERY_COOLDOWN), None); + + // A success clears it, and so does a refused query. + c.record(&lan, t0, None); + assert_eq!(c.active(&lan, t0), None); + c.record(&lan, t0, Some(&down)); + c.record(&lan, t0, Some(&embed::EmbedError { message: "HTTP 400: too long".into(), refused: true })); + assert_eq!(c.active(&lan, t0), None); + } + + #[test] + fn vectors_round_trip_and_normalize() { + let v = normalize(vec![3.0, 4.0]); + assert!((v[0] - 0.6).abs() < 1e-6 && (v[1] - 0.8).abs() < 1e-6); + assert_eq!(blob_to_vector(&vector_to_blob(&v)), v); + assert_eq!(normalize(vec![0.0, 0.0]), vec![0.0, 0.0]); + } +} diff --git a/src-tauri/src/sandbox.rs b/src-tauri/src/sandbox.rs new file mode 100644 index 0000000..3b1a5f6 --- /dev/null +++ b/src-tauri/src/sandbox.rs @@ -0,0 +1,216 @@ +//! The `loach-sandbox` URI scheme: an origin of its own for content Loach +//! renders but must not trust — the Code canvas's HTML preview. +//! +//! The protocol serves one fixed host page. The canvas frames it in +//! `<iframe sandbox="allow-scripts allow-forms">` and hands it the HTML over +//! `postMessage`; the page never learns anything else. What keeps that HTML +//! away from the app: +//! +//! * **An opaque origin.** The sandbox (no `allow-same-origin`) keeps the +//! page out of the app's DOM, storage and globals. +//! * **Its own CSP** ([`CSP`]), sent on every response: inline script and +//! style run, nothing is fetched — no network, no IPC endpoint, no nested +//! frames, no form submissions. This one is load-bearing for IPC: on +//! Windows wry injects Tauri's init scripts into *every* frame (see wry's +//! `with_initialization_script_for_main_only`), so the preview holds a +//! working `__TAURI_INTERNALS__.invoke` with the real invoke key, and +//! Tauri counts a registered custom protocol as a *local* origin. The +//! CSP blocks invoke's `ipc:` fetch, and WebView2 delivers its +//! `postMessage` fallback to the frame's own event, which wry doesn't +//! listen to — verified against Tauri 2.12 / wry 0.57 by invoking a +//! command from the frame and seeing it never run. Re-check that after a +//! Tauri or wry upgrade. (On macOS and Linux the scripts stay in the main +//! frame.) +//! * **The app's `frame-src`** (`tauri.conf.json`) allows only this scheme, +//! which also stops the preview navigating itself to an outside URL — the +//! webview's navigation hook only sees the main frame. +//! * **The host page** (`assets/sandbox/preview.html`) covers what no CSP +//! directive does. It takes WebRTC away before the HTML runs (a peer +//! connection's ICE traffic reaches a STUN server regardless of +//! `connect-src`), and removes nested frames as they're added: a `srcdoc` +//! or `javascript:` frame isn't stopped by `frame-src` and would run its +//! own script with a fresh `RTCPeerConnection`. +//! * **WebView2's WebRTC policy**, on Windows, behind the host page: the main +//! window's `additionalBrowserArgs` (`tauri.conf.json`) add +//! `--force-webrtc-ip-handling-policy=disable_non_proxied_udp`, so a peer +//! connection that got past the page still has no UDP to reach a STUN +//! server with. Setting the field replaces wry's own arguments +//! (`--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection`, the +//! `default_args` in wry 0.57's `src/webview2/mod.rs`), so they're repeated +//! ahead of it — re-check them after a wry upgrade. +//! +//! Previews start with the page's scripts off: the same host page is also +//! served as `preview-static.html` under [`STATIC_CSP`], which lets only the +//! host's own script run. On macOS and Linux WebKit runs the frame on the +//! app's main thread, so a page stuck in a loop froze all of Loach — lock +//! screen included — the moment it was previewed; the canvas now asks first. + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use once_cell::sync::Lazy; +use sha2::{Digest, Sha256}; +use tauri::http::{header, Request, Response, StatusCode}; + +/// Scheme name. The frame URL is `loach-sandbox://localhost/…` on macOS and +/// Linux and `http://loach-sandbox.localhost/…` on Windows; the frontend +/// builds it with `convertFileSrc`, and the app CSP's `frame-src` lists both. +pub const SCHEME: &str = "loach-sandbox"; + +const PREVIEW_HOST: &[u8] = include_bytes!("../assets/sandbox/preview.html"); + +/// Everything not named falls back to `default-src 'none'`: `connect-src` +/// (fetch, WebSocket, the IPC endpoint), `frame-src`, `worker-src`, +/// `object-src`. `form-action` and `base-uri` don't fall back, so they're +/// spelled out. Images, fonts and media are limited to what the page builds +/// itself (`data:` / `blob:`): a remote URL would load, and leak, from +/// outside. +const CSP: &str = "default-src 'none'; script-src 'unsafe-inline'; \ + style-src 'unsafe-inline'; img-src data: blob:; font-src data:; \ + media-src data: blob:; form-action 'none'; base-uri 'none'"; + +/// [`CSP`] with `script-src` narrowed to the host page's own script, by +/// hash: the scripts-off preview. The policy belongs to the document and +/// survives the host's `document.open()` / `write()`, so the page it renders +/// can't run inline scripts, event-handler attributes or `javascript:` URLs. +static STATIC_CSP: Lazy<String> = Lazy::new(|| { + CSP.replace( + "script-src 'unsafe-inline'", + &format!("script-src '{}'", host_script_hash()), + ) +}); + +/// CSP hash of the host page's inline script. +fn host_script_hash() -> String { + let page = std::str::from_utf8(PREVIEW_HOST).expect("preview.html is UTF-8"); + let start = page.find("<script>").expect("preview.html has its script") + "<script>".len(); + let end = start + page[start..].find("</script>").expect("its script is closed"); + script_hash(&page[start..end]) +} + +/// A CSP `sha256-` source for an inline script's text, as the browser +/// computes it: after the HTML parser has turned CRLF and lone CR into LF +/// (a Windows checkout gives preview.html CRLF line endings). +fn script_hash(text: &str) -> String { + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + format!("sha256-{}", STANDARD.encode(Sha256::digest(text.as_bytes()))) +} + +/// Protocol handler registered in `lib.rs`. Every response, the 404 too, +/// carries a CSP: [`CSP`], or [`STATIC_CSP`] for the scripts-off page. +pub fn handle(request: &Request<Vec<u8>>) -> Response<&'static [u8]> { + let (status, body, csp) = match request.uri().path() { + "/preview.html" => (StatusCode::OK, PREVIEW_HOST, CSP), + "/preview-static.html" => (StatusCode::OK, PREVIEW_HOST, STATIC_CSP.as_str()), + _ => (StatusCode::NOT_FOUND, &[][..], CSP), + }; + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(header::CONTENT_SECURITY_POLICY, csp) + .body(body) + .expect("static response parts are valid") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get(uri: &str) -> Response<&'static [u8]> { + handle(&Request::get(uri).body(Vec::new()).unwrap()) + } + + #[test] + fn serves_the_preview_host_on_both_url_shapes() { + for uri in [ + "loach-sandbox://localhost/preview.html", + "http://loach-sandbox.localhost/preview.html", + ] { + let res = get(uri); + assert_eq!(res.status(), StatusCode::OK, "{uri}"); + assert_eq!(*res.body(), PREVIEW_HOST); + } + } + + #[test] + fn the_scripts_off_page_runs_only_the_host_script() { + let res = get("loach-sandbox://localhost/preview-static.html"); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(*res.body(), PREVIEW_HOST); + let csp = res.headers()[header::CONTENT_SECURITY_POLICY].to_str().unwrap(); + // The host script by hash, and nothing that would let the page's own + // scripts, handlers or `javascript:` URLs run… + assert!(csp.contains(&format!("script-src '{}'", host_script_hash())), "{csp}"); + let script_src = csp.split(';').find(|d| d.trim().starts_with("script-src")).unwrap(); + assert!(!script_src.contains("'unsafe-inline'"), "{script_src}"); + // …while everything else stays exactly as locked down as the + // scripts-on page. + assert_eq!( + csp.replace(&format!("script-src '{}'", host_script_hash()), ""), + CSP.replace("script-src 'unsafe-inline'", ""), + ); + } + + #[test] + fn the_script_hash_is_taken_over_lf_line_endings() { + // A Windows checkout serves the page with CRLF; the browser hashes + // the script text with LF, so the CSP must too. + let expected = "sha256-kRFp3arxRq/1OfWMJsSJrzuJLf8P4oPBwmTGWuWqWaI="; + assert_eq!(script_hash("a\nb\n"), expected); + assert_eq!(script_hash("a\r\nb\r\n"), expected); + assert_eq!(script_hash("a\rb\r"), expected); + } + + #[test] + fn unknown_paths_are_not_found() { + for uri in [ + "loach-sandbox://localhost/", + "loach-sandbox://localhost/other.html", + "loach-sandbox://localhost/../preview.html", + "loach-sandbox://localhost/sandbox/preview.html", + ] { + let res = get(uri); + assert_eq!(res.status(), StatusCode::NOT_FOUND, "{uri}"); + assert!(res.body().is_empty()); + } + } + + #[test] + fn every_response_carries_the_csp() { + for uri in ["loach-sandbox://localhost/preview.html", "loach-sandbox://localhost/nope"] { + let res = get(uri); + assert_eq!(res.headers()[header::CONTENT_SECURITY_POLICY], CSP, "{uri}"); + } + } + + #[test] + fn csp_fetches_nothing_from_outside() { + for d in ["default-src 'none'", "form-action 'none'", "base-uri 'none'"] { + assert!(CSP.contains(d), "missing `{d}`"); + } + // Sources stay limited to inline code and URLs the page builds itself… + for token in CSP.split([';', ' ']).filter(|t| !t.is_empty()) { + assert!( + token.ends_with("-src") + || token.ends_with("-action") + || token.ends_with("-uri") + || ["'none'", "'unsafe-inline'", "data:", "blob:"].contains(&token), + "unexpected source `{token}`" + ); + } + // …and nothing re-opens what `default-src 'none'` closes. + for name in ["connect-src", "frame-src", "child-src", "worker-src", "object-src"] { + assert!(!CSP.contains(name), "`{name}` must stay on the default-src fallback"); + } + } + + /// The CSP can't stop WebRTC, so the host page has to: it takes every + /// constructor away and keeps the page from opening a frame that would + /// bring them back. + #[test] + fn the_host_page_takes_webrtc_away() { + let page = std::str::from_utf8(PREVIEW_HOST).unwrap(); + for name in ["RTCPeerConnection", "webkitRTCPeerConnection", "RTCDataChannel"] { + assert!(page.contains(&format!("\"{name}\"")), "`{name}` is left in place"); + } + assert!(page.contains("new MutationObserver"), "nested frames are no longer removed"); + } +} diff --git a/src-tauri/src/secrets.rs b/src-tauri/src/secrets.rs index 1372dd8..63b8f4b 100644 --- a/src-tauri/src/secrets.rs +++ b/src-tauri/src/secrets.rs @@ -10,8 +10,43 @@ const OPENAI_ACCOUNT: &str = "openai_api_key"; /// log file) by stuffing megabytes of garbage in. const MAX_KEY_BYTES: usize = 2048; +/// Windows Credential Manager stores the key as UTF-16 in a blob of at most +/// 2560 bytes (`CRED_MAX_CREDENTIAL_BLOB_SIZE`), so there anything over 1280 +/// UTF-16 units fails inside the keyring with a raw `TooLong`. Checked up +/// front so such a key gets the same message as any other over-long one. +const WINDOWS_MAX_BLOB_BYTES: usize = 2560; + +fn key_fits(key: &str) -> bool { + key.len() <= MAX_KEY_BYTES + && (!cfg!(windows) || key.encode_utf16().count() * 2 <= WINDOWS_MAX_BLOB_BYTES) +} + +/// A keyring entry for `service` / `user`. +/// +/// keyring's `v1` feature sets the platform store up once per process (a +/// `LazyLock`) and keeps a failure for good. On Linux that's a Secret Service +/// connect, so a session bus or keyring daemon that wasn't up yet at the first +/// call failed every later one with `NoDefaultStore` until a restart — the +/// lock read as unconfigured on installs without the marker file, and the +/// OpenAI key couldn't be read or saved. keyring-core's default store can be +/// replaced at any time, so on that error connect again and build the entry +/// on the new store; a connect that still fails is retried on the next call. +pub fn keyring_entry(service: &str, user: &str) -> keyring::Result<Entry> { + let entry = Entry::new(service, user); + #[cfg(target_os = "linux")] + if matches!(entry, Err(keyring::Error::NoDefaultStore)) { + if keyring_core::get_default_store().is_none() { + keyring_core::set_default_store(zbus_secret_service_keyring_store::Store::new()?); + } + return Ok(Entry { + inner: keyring_core::Entry::new(service, user)?, + }); + } + entry +} + fn entry() -> Result<Entry> { - Ok(Entry::new(SERVICE, OPENAI_ACCOUNT)?) + Ok(keyring_entry(SERVICE, OPENAI_ACCOUNT)?) } pub fn set_openai_key(key: &str) -> Result<()> { @@ -23,7 +58,7 @@ pub fn set_openai_key(key: &str) -> Result<()> { if trimmed.is_empty() { return Err(anyhow!("OpenAI API key is empty")); } - if trimmed.len() > MAX_KEY_BYTES { + if !key_fits(trimmed) { return Err(anyhow!( "OpenAI API key is unusually long ({} chars) — refusing to store", trimmed.len() @@ -57,3 +92,19 @@ pub fn clear_openai_key() -> Result<()> { Err(e) => Err(e.into()), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A key Windows Credential Manager can't hold is refused here with the + /// friendly message, not by the keyring with a raw `TooLong`. + #[test] + fn key_length_matches_the_platform_limit() { + assert!(key_fits("sk-proj-abc")); + assert!(key_fits(&"k".repeat(1280))); + assert_eq!(key_fits(&"k".repeat(1281)), !cfg!(windows)); + assert_eq!(key_fits(&"k".repeat(MAX_KEY_BYTES)), !cfg!(windows)); + assert!(!key_fits(&"k".repeat(MAX_KEY_BYTES + 1))); + } +} diff --git a/src-tauri/src/security.rs b/src-tauri/src/security.rs index 67d4bde..cea405c 100644 --- a/src-tauri/src/security.rs +++ b/src-tauri/src/security.rs @@ -17,32 +17,85 @@ // `PoisonError` boilerplate that `std::sync::Mutex` requires on every // `.lock()` call and panics on first poison. use parking_lot::Mutex; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use anyhow::{anyhow, bail, Result}; -use argon2::{ - password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, - Algorithm, Argon2, Params, Version, -}; +use argon2::{Algorithm, Argon2, Params, PasswordHash, PasswordHasher, PasswordVerifier, Version}; use keyring::Entry; -use once_cell::sync::Lazy; -use rand_core::OsRng; +use once_cell::sync::{Lazy, OnceCell}; use serde::{Deserialize, Serialize}; const SERVICE: &str = "dev.loach.app"; const ACCOUNT: &str = "app_lock"; +// --------------------------------------------------------------------------- +// Lock marker. +// +// A copy of the non-secret `LockStatus`, kept in the app data dir, so the +// startup probe can tell whether a lock is configured without reading the +// keyring. Reading the keyring there let the lock fail open: the probe ran +// before anything was shown, and a keychain access prompt (macOS asks again +// after every update of an ad-hoc-signed build) that was denied or answered +// slowly made the renderer treat the app as unlocked. With the marker the +// lock screen shows at once and the keyring is first read on unlock, where +// a refused read keeps the app locked. +// +// The keyring stays the source of truth: the marker is rewritten whenever +// the lock changes, removed when it can't be, and when it's missing (first +// launch on this version, or a fresh install) `status` reads the keyring +// and writes it. +// --------------------------------------------------------------------------- + +static MARKER_PATH: OnceCell<PathBuf> = OnceCell::new(); + +/// Point the marker — and the saved rate-limit state, see below — at the app +/// data dir, and load that state. Called once from `lib.rs` setup; until +/// then (and in tests) the marker is skipped and `status` reads the keyring +/// every time, as before, and the rate limit lives in memory only. +pub fn init_marker(data_dir: &Path) { + let _ = MARKER_PATH.set(data_dir.join("app_lock.json")); + let _ = ATTEMPTS_PATH.set(data_dir.join("app_lock_attempts.json")); + *UNLOCK_STATE.lock() = load_attempts(); +} + +fn read_marker() -> Option<LockStatus> { + let raw = std::fs::read_to_string(MARKER_PATH.get()?).ok()?; + serde_json::from_str(&raw).ok() +} + +/// Best effort. A marker that can't be written is removed instead, so it +/// never outlives the keyring entry it describes. +fn write_marker(status: &LockStatus) { + let Some(path) = MARKER_PATH.get() else { + return; + }; + let written = serde_json::to_string(status) + .map_err(anyhow::Error::from) + .and_then(|json| std::fs::write(path, json).map_err(anyhow::Error::from)); + if let Err(e) = written { + tracing::warn!("couldn't write the app-lock marker: {e:#}"); + let _ = std::fs::remove_file(path); + } +} + // --------------------------------------------------------------------------- // Rate-limit state for `unlock`. // // Tracks consecutive failed attempts and, once `LOCKOUT_THRESHOLD` is -// exceeded, refuses further attempts for an escalating window. The state is -// held in-memory at module scope — surviving across IPC calls but reset on -// app restart, which is the right trade-off for a desktop app: persisting -// the counter to disk would let an attacker reset it by deleting a file, -// while not persisting it means the bound applies to the (short-lived) IPC -// session the attacker has — they still pay the wall-clock cost on each -// fresh launch. +// exceeded, refuses further attempts for an escalating window. Every check +// runs through `rate_limited`, which holds the state lock from the lockout +// check through the verify to recording the outcome — otherwise parallel +// calls (`unlock` racing `require_unlocked`, …) all passed the check before +// any of them recorded a failure. +// +// The state is also saved next to the marker (`app_lock_attempts.json`) and +// loaded on start. Kept in memory only, it reset on every restart, and the +// renderer can restart the app (`process:default` grants `request_restart`) +// — so a compromised renderer could brute-force the lock by restarting +// between bursts. Deleting the file still resets it, but that takes access +// to the user's files, not just the renderer; a missing or unreadable file +// means no lockout, so a damaged one can never lock the user out for good. // --------------------------------------------------------------------------- #[derive(Default)] @@ -54,6 +107,72 @@ struct UnlockState { static UNLOCK_STATE: Lazy<Mutex<UnlockState>> = Lazy::new(|| Mutex::new(UnlockState::default())); +static ATTEMPTS_PATH: OnceCell<PathBuf> = OnceCell::new(); + +/// [`UnlockState`] as it's saved: an `Instant` means nothing to the next +/// process, so the window's end is kept as wall-clock milliseconds. +#[derive(Debug, Serialize, Deserialize, PartialEq)] +struct SavedAttempts { + consecutive_failures: u32, + locked_until_ms: Option<i64>, +} + +impl UnlockState { + fn to_saved(&self, now: Instant, now_ms: i64) -> SavedAttempts { + SavedAttempts { + consecutive_failures: self.consecutive_failures, + locked_until_ms: self.locked_until.map(|until| { + now_ms + i64::try_from(until.saturating_duration_since(now).as_millis()).unwrap_or(0) + }), + } + } + + /// The remaining window is capped at the longest one ever handed out, so + /// a clock set back since the save can't stretch it. + fn from_saved(saved: SavedAttempts, now: Instant, now_ms: i64) -> Self { + let max_ms = MAX_LOCKOUT_SECS as i64 * 1000; + UnlockState { + consecutive_failures: saved.consecutive_failures, + locked_until: saved.locked_until_ms.map(|until_ms| { + now + Duration::from_millis((until_ms - now_ms).clamp(0, max_ms) as u64) + }), + } + } +} + +fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() +} + +fn load_attempts() -> UnlockState { + ATTEMPTS_PATH + .get() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|raw| serde_json::from_str(&raw).ok()) + .map(|saved| UnlockState::from_saved(saved, Instant::now(), now_ms())) + .unwrap_or_default() +} + +/// Best effort, like the marker. A clean slate is saved as no file at all. +fn save_attempts(st: &UnlockState) { + let Some(path) = ATTEMPTS_PATH.get() else { + return; + }; + let result = if st.consecutive_failures == 0 { + match std::fs::remove_file(path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(anyhow::Error::from(e)), + _ => Ok(()), + } + } else { + serde_json::to_string(&st.to_saved(Instant::now(), now_ms())) + .map_err(anyhow::Error::from) + .and_then(|json| std::fs::write(path, json).map_err(anyhow::Error::from)) + }; + if let Err(e) = result { + tracing::warn!("couldn't save the app-lock attempt count: {e:#}"); + } +} + const LOCKOUT_THRESHOLD: u32 = 5; /// First lockout is 30 s; each additional failure doubles, capped at ~2 h. const BASE_LOCKOUT_SECS: u64 = 30; @@ -97,7 +216,8 @@ pub struct LockStatus { } fn entry() -> Result<Entry> { - Ok(Entry::new(SERVICE, ACCOUNT)?) + // Not `Entry::new`: see `keyring_entry` for the Linux retry. + Ok(crate::secrets::keyring_entry(SERVICE, ACCOUNT)?) } fn load() -> Result<Option<LockConfig>> { @@ -111,6 +231,7 @@ fn load() -> Result<Option<LockConfig>> { fn save(cfg: &LockConfig) -> Result<()> { let json = serde_json::to_string(cfg)?; entry()?.set_password(&json)?; + write_marker(&status_of(Some(cfg))); Ok(()) } @@ -136,12 +257,11 @@ fn argon2_instance() -> Argon2<'static> { } fn hash(secret: &str) -> Result<String> { - let salt = SaltString::generate(&mut OsRng); - let phc = argon2_instance() - .hash_password(secret.as_bytes(), &salt) - .map_err(|e| anyhow!("argon2 hash failed: {e}"))? - .to_string(); - Ok(phc) + // `hash_password` draws the salt from the OS RNG itself. + let phc: PasswordHash = argon2_instance() + .hash_password(secret.as_bytes()) + .map_err(|e| anyhow!("argon2 hash failed: {e}"))?; + Ok(phc.to_string()) } fn verify(secret: &str, phc: &str) -> bool { @@ -163,8 +283,19 @@ fn verify(secret: &str, phc: &str) -> bool { // Public API — wired through `commands.rs` to the frontend. // --------------------------------------------------------------------------- +/// Whether a lock is configured, for the startup probe. Answers from the +/// marker when there is one; see the "Lock marker" note above. pub fn status() -> Result<LockStatus> { - Ok(match load()? { + if let Some(marker) = read_marker() { + return Ok(marker); + } + let status = status_of(load()?.as_ref()); + write_marker(&status); + Ok(status) +} + +fn status_of(cfg: Option<&LockConfig>) -> LockStatus { + match cfg { None => LockStatus { configured: false, method: None, @@ -177,15 +308,14 @@ pub fn status() -> Result<LockStatus> { pin_length: c.pin_length, has_hint: c.hint.is_some(), }, - }) + } } /// Check whether we're currently inside the rate-limit cool-down. Returns /// an error with the remaining time when locked; `Ok(())` otherwise. Used /// before any argon2 verification so brute-force callers can't even trigger /// a hash during the window. -fn check_lockout() -> Result<()> { - let st = UNLOCK_STATE.lock(); +fn check_lockout(st: &UnlockState) -> Result<()> { if let Some(until) = st.locked_until { let now = Instant::now(); if until > now { @@ -204,8 +334,7 @@ fn check_lockout() -> Result<()> { /// lockout state. On success, the counter is reset. On failure, the counter /// is bumped and, once past the threshold, an exponentially-growing cool- /// down window is set. -fn record_attempt(ok: bool) { - let mut st = UNLOCK_STATE.lock(); +fn record_attempt(st: &mut UnlockState, ok: bool) { if ok { st.consecutive_failures = 0; st.locked_until = None; @@ -222,6 +351,19 @@ fn record_attempt(ok: bool) { } } +/// One credential check under the rate limit: refused during a cool-down, +/// otherwise `verify` runs and its outcome is recorded and saved — all with +/// the state locked, so concurrent checks queue up behind each other (see +/// the "Rate-limit state" note). +fn rate_limited(verify: impl FnOnce() -> bool) -> Result<bool> { + let mut st = UNLOCK_STATE.lock(); + check_lockout(&st)?; + let ok = verify(); + record_attempt(&mut st, ok); + save_attempts(&st); + Ok(ok) +} + /// Verify pin + password against a loaded config. Returns true only when /// every factor the method requires checks out. fn verify_against_config( @@ -270,10 +412,7 @@ pub fn setup( // exponential cool-down. The initial-setup case (no existing config) // skips both checks because there's nothing to brute-force. if let Some(existing) = load()? { - check_lockout()?; - let ok = verify_against_config(&existing, current_pin, current_password); - record_attempt(ok); - if !ok { + if !rate_limited(|| verify_against_config(&existing, current_pin, current_password))? { bail!("Current credentials are required to change the app lock."); } } @@ -347,22 +486,28 @@ pub fn setup( /// rejects further attempts (without even hashing) for an exponentially /// growing window. A successful unlock resets the counter. pub fn unlock(pin: Option<&str>, password: Option<&str>) -> Result<bool> { - check_lockout()?; + // Early out before the keyring read; `rate_limited` checks again. + check_lockout(&UNLOCK_STATE.lock())?; let cfg = match load()? { Some(c) => c, - None => return Ok(true), + None => { + // The keyring entry is gone (removed outside the app) while the + // marker still put up the lock screen. Correct the marker, or + // every launch would show a lock that any input opens. + write_marker(&status_of(None)); + return Ok(true); + } }; - let ok = verify_against_config(&cfg, pin, password); - record_attempt(ok); - Ok(ok) + rate_limited(|| verify_against_config(&cfg, pin, password)) } fn reset_unlock_state() { let mut st = UNLOCK_STATE.lock(); st.consecutive_failures = 0; st.locked_until = None; + save_attempts(&st); } pub fn get_hint() -> Result<Option<String>> { @@ -378,6 +523,9 @@ pub fn clear() -> Result<()> { Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(e.into()), }; + if result.is_ok() { + write_marker(&status_of(None)); + } // Clearing the lock invalidates any in-flight lockout — the keyring // entry that the cool-down was guarding is now gone. reset_unlock_state(); @@ -393,10 +541,7 @@ pub fn clear_with_credentials( current_password: Option<&str>, ) -> Result<()> { if let Some(cfg) = load()? { - check_lockout()?; - let ok = verify_against_config(&cfg, current_pin, current_password); - record_attempt(ok); - if !ok { + if !rate_limited(|| verify_against_config(&cfg, current_pin, current_password))? { bail!("Current credentials are required to remove the app lock."); } } @@ -419,10 +564,7 @@ pub fn require_unlocked( current_password: Option<&str>, ) -> Result<()> { if let Some(cfg) = load()? { - check_lockout()?; - let ok = verify_against_config(&cfg, current_pin, current_password); - record_attempt(ok); - if !ok { + if !rate_limited(|| verify_against_config(&cfg, current_pin, current_password))? { bail!("Current app-lock credentials are required for this action."); } } @@ -452,6 +594,114 @@ mod tests { /// `UNLOCK_STATE` and run free. static LOCKOUT_TEST_LOCK: Lazy<PMutex<()>> = Lazy::new(|| PMutex::new(())); + #[test] + fn status_answers_from_the_marker_without_the_keyring() { + // The marker path is process-wide and set once; this is the only test + // that sets it, and the only one that calls `status`. Setting it also + // loads `UNLOCK_STATE`, hence the lock. + let _g = LOCKOUT_TEST_LOCK.lock(); + let dir = tempfile::tempdir().unwrap(); + init_marker(dir.path()); + let cfg = LockConfig { + method: LockMethod::Pin, + pin_length: Some(4), + pin_hash: Some("unused".into()), + password_hash: None, + hint: Some("the usual".into()), + }; + write_marker(&status_of(Some(&cfg))); + + // With a marker present the keyring is never read, so a keychain + // prompt can't stand between startup and the lock screen. + let s = status().expect("answered from the marker"); + assert!(s.configured); + assert_eq!(s.method, Some(LockMethod::Pin)); + assert_eq!(s.pin_length, Some(4)); + assert!(s.has_hint); + + // A torn write reads as no marker, which sends `status` back to the + // keyring instead of trusting a half-written answer. + std::fs::write(dir.path().join("app_lock.json"), "{\"configured\":tr").unwrap(); + assert!(read_marker().is_none()); + + // The attempt count lives beside the marker, so a restart — which + // the renderer can ask for — doesn't wipe a lockout… + let attempts = dir.path().join("app_lock_attempts.json"); + for _ in 0..LOCKOUT_THRESHOLD { + rate_limited(|| false).unwrap(); + } + assert!(attempts.exists()); + *UNLOCK_STATE.lock() = load_attempts(); // as the next launch would + assert!(check_lockout(&UNLOCK_STATE.lock()).is_err(), "the lockout didn't survive"); + // …a clean slate is no file at all… + reset_unlock_state(); + assert!(!attempts.exists()); + // …and a damaged file means no lockout rather than a permanent one. + std::fs::write(&attempts, "{\"consecutive_failures\":9,\"locked_until_ms\":").unwrap(); + assert!(check_lockout(&load_attempts()).is_ok()); + reset_unlock_state(); + } + + /// Saved and loaded across a restart, the window keeps its end — but a + /// clock set back since can't stretch it past the longest window. + #[test] + fn saved_attempts_keep_the_window_and_cap_it() { + let now = Instant::now(); + let st = UnlockState { + consecutive_failures: 6, + locked_until: Some(now + Duration::from_secs(60)), + }; + let saved = st.to_saved(now, 1_000_000); + assert_eq!( + saved, + SavedAttempts { consecutive_failures: 6, locked_until_ms: Some(1_060_000) } + ); + + // Twenty seconds later, in the next process. + let later = Instant::now(); + let loaded = UnlockState::from_saved(saved, later, 1_020_000); + assert_eq!(loaded.consecutive_failures, 6); + assert_eq!(loaded.locked_until, Some(later + Duration::from_secs(40))); + assert!(check_lockout(&loaded).is_err()); + + // Already over: no lockout. Clock moved back a day: capped. + let over = SavedAttempts { consecutive_failures: 6, locked_until_ms: Some(900_000) }; + assert!(check_lockout(&UnlockState::from_saved(over, later, 1_000_000)).is_ok()); + let skewed = SavedAttempts { + consecutive_failures: 6, + locked_until_ms: Some(1_000_000 + 24 * 3600 * 1000), + }; + let capped = UnlockState::from_saved(skewed, later, 1_000_000); + assert_eq!(capped.locked_until, Some(later + Duration::from_secs(MAX_LOCKOUT_SECS))); + } + + /// Regression: the lockout check, the verify and the recording weren't + /// one step, so parallel checks all got past the check before any of + /// them recorded its failure. Now only `LOCKOUT_THRESHOLD` get to verify. + #[test] + fn concurrent_checks_cannot_outrun_the_lockout() { + let _g = LOCKOUT_TEST_LOCK.lock(); + reset_unlock_state(); + let verified = std::sync::atomic::AtomicU32::new(0); + let refused = std::thread::scope(|s| { + let calls: Vec<_> = (0..LOCKOUT_THRESHOLD * 3) + .map(|_| { + s.spawn(|| { + rate_limited(|| { + verified.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(5)); + false + }) + }) + }) + .collect(); + calls.into_iter().map(|c| c.join().unwrap()).filter(Result::is_err).count() + }); + assert_eq!(verified.into_inner(), LOCKOUT_THRESHOLD); + assert_eq!(refused as u32, LOCKOUT_THRESHOLD * 2); + reset_unlock_state(); + } + #[test] fn hash_then_verify_roundtrips() { let phc = hash("hunter2-correct-horse").expect("hash succeeds"); @@ -479,6 +729,21 @@ mod tests { assert!(!verify("anything", "")); } + #[test] + fn verify_accepts_hashes_stored_by_argon2_0_5() { + // Made by argon2 0.5.3, the version that shipped up to Loach 1.5.0, + // and sitting in users' credential stores. A crate bump that stops + // verifying them locks those users out of the app. + let current_params = "$argon2id$v=19$m=65536,t=3,p=1$Hr9IRw89uglbWlWcxOZrJw$rr0xcfZgwJYES0b7Kf1M8bnY2m1csUk30fTLYapBVzI"; + assert!(verify("1234", current_params)); + assert!(!verify("0000", current_params)); + + // `Argon2::default()` parameters, from before the cost was raised. + let default_params = "$argon2id$v=19$m=19456,t=2,p=1$FXKWcHr77AyDYYzmY+CrwA$Zto6EKKlbuktJuMGNwUMpdpmdpC/dM9Ir+XeawQvRx4"; + assert!(verify("correct-horse", default_params)); + assert!(!verify("wrong", default_params)); + } + #[test] fn verify_against_config_requires_all_factors() { let pin_phc = hash("1234").unwrap(); @@ -540,17 +805,16 @@ mod tests { #[test] fn lockout_after_threshold_failures() { - let _g = LOCKOUT_TEST_LOCK.lock(); - reset_unlock_state(); + let mut st = UnlockState::default(); // Below threshold: still allowed to attempt. for _ in 0..(LOCKOUT_THRESHOLD - 1) { - record_attempt(false); - check_lockout().expect("not yet locked out below threshold"); + record_attempt(&mut st, false); + check_lockout(&st).expect("not yet locked out below threshold"); } // Threshold-th failure trips the lockout window. - record_attempt(false); - let err = check_lockout() + record_attempt(&mut st, false); + let err = check_lockout(&st) .err() .expect("expected lockout after threshold"); let msg = err.to_string(); @@ -560,23 +824,20 @@ mod tests { ); // A successful attempt clears the counter and the lockout. - reset_unlock_state(); - record_attempt(true); - check_lockout().expect("lockout cleared after success"); + record_attempt(&mut st, true); + check_lockout(&st).expect("lockout cleared after success"); } #[test] fn lockout_window_scales_exponentially_up_to_cap() { - let _g = LOCKOUT_TEST_LOCK.lock(); - reset_unlock_state(); + let mut st = UnlockState::default(); // Push the counter way past the cap-shift threshold (12). The // `locked_until` instant should never exceed `MAX_LOCKOUT_SECS` in // the future, even at unbounded failure counts. for _ in 0..(LOCKOUT_THRESHOLD + 20) { - record_attempt(false); + record_attempt(&mut st, false); } - let st = UNLOCK_STATE.lock(); let until = st.locked_until.expect("locked out"); let now = Instant::now(); let remaining = until.saturating_duration_since(now).as_secs(); @@ -590,7 +851,5 @@ mod tests { remaining >= BASE_LOCKOUT_SECS, "lockout {remaining}s shorter than BASE_LOCKOUT_SECS={BASE_LOCKOUT_SECS}s" ); - drop(st); - reset_unlock_state(); } } diff --git a/src-tauri/src/sse.rs b/src-tauri/src/sse.rs index 58cb5a0..bf3178c 100644 --- a/src-tauri/src/sse.rs +++ b/src-tauri/src/sse.rs @@ -2,32 +2,57 @@ //! the OpenAI-compatible chat stream (`providers::openai`) and the MCP //! Streamable-HTTP client (`mcp::client`). //! -//! Only the frame-boundary scan lives here. Everything above it — what a -//! `data:` line means, how `[DONE]` terminates a run — is transport-specific -//! and stays with its owner. +//! Only the frame-boundary scan and line splitting live here. Everything +//! above it — what a `data:` line means, how `[DONE]` terminates a run — is +//! transport-specific and stays with its owner. -/// Position and length of the blank line ending the first frame in `buf`. +/// Length of the line ending at `buf[i]`, if one starts there. The spec +/// allows `\r\n`, `\n` and a lone `\r`; `\r\n` is always one ending, never a +/// `\r` line followed by an empty `\n` one. +fn line_end_at(buf: &[u8], i: usize) -> Option<usize> { + match buf.get(i)? { + b'\r' if buf.get(i + 1) == Some(&b'\n') => Some(2), + b'\r' | b'\n' => Some(1), + _ => None, + } +} + +/// Position and length of the blank line ending the first frame in `buf`: +/// a line ending followed straight by another, in any mix of the three the +/// spec allows (`\n\n`, `\r\n\r\n`, `\r\r`, `\n\r\n`, …). /// /// Returns the delimiter length too so the caller consumes all of it — /// dropping only 2 bytes of a `\r\n\r\n` would leave a stray `\r\n` glued to -/// the front of the next frame. When both forms match at overlapping offsets -/// the earlier one wins, and a CRLF pair is reported at its `\r` so the frame -/// text excludes it. +/// the front of the next frame. The position is where the first ending +/// starts, so the frame text excludes it. +/// +/// A `\r` that is the last byte of `buf` may be the start of a `\r\n` still +/// in flight. As the second ending it already settles that the frame is over, +/// so the frame is reported rather than held back; if a `\n` does follow, it +/// is left at the head of the next frame, where it reads as an empty line. pub fn find_frame_end(buf: &[u8]) -> Option<(usize, usize)> { - let lf = buf.windows(2).position(|w| w == b"\n\n"); - let crlf = buf.windows(4).position(|w| w == b"\r\n\r\n"); - match (lf, crlf) { - (Some(a), Some(b)) => { - if a <= b { - Some((a, 2)) - } else { - Some((b, 4)) + let mut i = 0; + while i < buf.len() { + match line_end_at(buf, i) { + Some(first) => { + if let Some(second) = line_end_at(buf, i + first) { + return Some((i, first + second)); + } + i += first; } + None => i += 1, } - (Some(a), None) => Some((a, 2)), - (None, Some(b)) => Some((b, 4)), - (None, None) => None, } + None +} + +/// The lines of one frame, split at any of the spec's line endings — `\r\n`, +/// `\n` or a lone `\r`. `str::lines` knows only the first two, so a server +/// ending its lines with `\r` alone had a whole frame read as one line. +pub fn frame_lines(frame: &str) -> impl Iterator<Item = &str> { + frame.split('\n').flat_map(|line| { + line.strip_suffix('\r').unwrap_or(line).split('\r') + }) } #[cfg(test)] @@ -57,5 +82,39 @@ mod tests { assert_eq!(find_frame_end(b""), None); // A single CRLF is a line break inside a frame, not a frame end. assert_eq!(find_frame_end(b"data: x\r\ny"), None); + assert_eq!(find_frame_end(b"data: x\ry"), None); + // A trailing `\r` may be half of a `\r\n`; one line ending so far. + assert_eq!(find_frame_end(b"data: x\r"), None); + assert_eq!(find_frame_end(b"data: x\r\n"), None); + } + + /// Every pairing of the spec's three line endings ends a frame — these + /// used to sit in the buffer until the 1 MiB abort. + #[test] + fn cr_only_and_mixed_endings_end_a_frame() { + assert_eq!(find_frame_end(b"data: x\r\rrest"), Some((7, 2))); + assert_eq!(find_frame_end(b"data: x\n\r\nrest"), Some((7, 3))); + assert_eq!(find_frame_end(b"data: x\r\n\nrest"), Some((7, 3))); + assert_eq!(find_frame_end(b"data: x\r\r\nrest"), Some((7, 3))); + assert_eq!(find_frame_end(b"data: x\n\rrest"), Some((7, 2))); + // Lines inside the frame may end any way too. + assert_eq!(find_frame_end(b"event: m\rdata: x\r\r"), Some((16, 2))); + } + + /// A `\r` at the end of what has arrived ends the frame when it is the + /// second ending — whatever follows it can't undo that. + #[test] + fn a_trailing_cr_as_the_second_ending_settles_the_frame() { + assert_eq!(find_frame_end(b"data: x\r\n\r"), Some((7, 3))); + assert_eq!(find_frame_end(b"data: x\n\r"), Some((7, 2))); + } + + #[test] + fn frame_lines_splits_at_every_line_ending() { + let lines: Vec<&str> = frame_lines("event: m\r\ndata: a\ndata: b\rid: 7").collect(); + assert_eq!(lines, ["event: m", "data: a", "data: b", "id: 7"]); + // `\r\n` is one ending, not a line plus an empty one. + assert_eq!(frame_lines("a\r\nb").collect::<Vec<_>>(), ["a", "b"]); + assert_eq!(frame_lines("a\r\rb").collect::<Vec<_>>(), ["a", "", "b"]); } } diff --git a/src-tauri/src/stream.rs b/src-tauri/src/stream.rs index 112cc43..658624b 100644 --- a/src-tauri/src/stream.rs +++ b/src-tauri/src/stream.rs @@ -73,10 +73,22 @@ impl StreamRegistry { } } + /// Retire `id` whoever holds it. Callers that have their handle use + /// [`finish_own`](Self::finish_own). pub fn finish(&self, id: &str) { self.chats.remove(id); self.inner.remove(id); } + + /// Retire `id` — but only while it is still `own`'s. After a collision + /// (see [`register`](Self::register)) the old stream winds down and + /// finishes too; removing by id alone took the newer stream's handle with + /// it, and that stream could no longer be stopped. + pub fn finish_own(&self, id: &str, own: &Arc<Notify>) { + if self.inner.remove_if(id, |_, n| Arc::ptr_eq(n, own)).is_some() { + self.chats.remove(id); + } + } } #[derive(Debug, Serialize, Clone)] @@ -362,6 +374,34 @@ mod tests { assert!(woke.is_err(), "no permit should exist after finish()"); } + /// The stream a collision replaced finishes after the newcomer has + /// registered. Its `finish_own` must leave the newcomer's handle in + /// place — Stop and the reload sweep still have to reach it — while the + /// newcomer's own finish retires the id. + #[tokio::test] + async fn a_replaced_streams_finish_leaves_the_new_handle_alone() { + let reg = StreamRegistry::new(); + let old = reg.register_chat("dup3".into()); + let new = reg.register_chat("dup3".into()); + reg.finish_own("dup3", &old); + reg.cancel("dup3"); + timeout(Duration::from_secs(1), new.notified()) + .await + .expect("the newer stream must still be cancellable"); + + let newer = reg.register_chat("dup4".into()); + reg.finish_own("dup4", &old); + assert_eq!(reg.cancel_chats(), 1, "still in the reload sweep"); + assert!(timeout(Duration::from_millis(50), newer.notified()).await.is_ok()); + + let own = reg.register_chat("dup5".into()); + reg.finish_own("dup5", &own); + reg.cancel("dup5"); + let woke = timeout(Duration::from_millis(50), own.notified()).await; + assert!(woke.is_err(), "its own finish retires the id"); + assert_eq!(reg.cancel_chats(), 0); + } + /// After a reload the chat streams are stopped; a model pull running /// through the same registry is not a chat and carries on. #[tokio::test] diff --git a/src-tauri/src/tools/base64_tool.rs b/src-tauri/src/tools/base64_tool.rs index 765c076..2109eef 100644 --- a/src-tauri/src/tools/base64_tool.rs +++ b/src-tauri/src/tools/base64_tool.rs @@ -18,7 +18,8 @@ pub fn tool_description() -> &'static str { (returning the bytes as hex if the result isn't valid UTF-8). Modes: \ `encode` (default), `decode`. Alphabets: `standard` (default, RFC 4648 \ §4 with `+` and `/`) or `url_safe` (RFC 4648 §5 with `-` and `_`). \ - Padding (`=`) is added on encode and accepted-but-not-required on decode." + Padding (`=`) is added on encode and accepted-but-not-required on decode; \ + whitespace and line breaks in the input are ignored on decode." } pub fn input_schema() -> Value { @@ -70,20 +71,9 @@ pub fn dispatch(args: &Value) -> McpCallResult { ..Default::default() }, "decode" => { - // Be permissive about missing padding — match the convention - // most user-facing base64 tools (incl. `python -m base64`) use. - let decoded = match engine.decode(input.trim()) { + let decoded = match decode_lenient(input, alphabet == "url_safe") { Ok(bytes) => bytes, - Err(_) => { - let lenient = match alphabet { - "url_safe" => &base64::engine::general_purpose::URL_SAFE_NO_PAD, - _ => &base64::engine::general_purpose::STANDARD_NO_PAD, - }; - match lenient.decode(input.trim()) { - Ok(b) => b, - Err(e) => return err(format!("invalid base64 input: {e}")), - } - } + Err(e) => return err(format!("invalid base64 input: {e}")), }; McpCallResult { content_text: match String::from_utf8(decoded.clone()) { @@ -100,6 +90,22 @@ pub fn dispatch(args: &Value) -> McpCallResult { } } +/// Decode base64 the way it gets pasted. Padding is optional — the +/// convention most user-facing base64 tools (incl. `python -m base64`) +/// use — and whitespace anywhere is dropped first: PEM bodies wrap every 64 +/// columns and MIME parts every 76, and trimming only the ends refused them. +/// Shared with `hash`'s base64 input. +pub fn decode_lenient(input: &str, url_safe: bool) -> Result<Vec<u8>, base64::DecodeError> { + use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD}; + let cleaned: String = input.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + let (padded, unpadded) = if url_safe { + (&URL_SAFE, &URL_SAFE_NO_PAD) + } else { + (&STANDARD, &STANDARD_NO_PAD) + }; + padded.decode(&cleaned).or_else(|_| unpadded.decode(&cleaned)) +} + fn hex(bytes: &[u8]) -> String { let mut s = String::with_capacity(bytes.len() * 2); for b in bytes { @@ -159,4 +165,12 @@ mod tests { let r = dispatch(&json!({"input": "@@@@", "op": "decode"})); assert!(r.is_error); } + + /// PEM and MIME wrap base64 into fixed-width lines. + #[test] + fn decode_ignores_line_breaks_inside_the_input() { + let r = dispatch(&json!({"input": "aGVs\r\nbG8g\n d29y bGQ=\n", "op": "decode"})); + assert!(!r.is_error, "{}", r.content_text); + assert_eq!(r.content_text, "hello world"); + } } diff --git a/src-tauri/src/tools/calculate.rs b/src-tauri/src/tools/calculate.rs index acc5d00..62222c6 100644 --- a/src-tauri/src/tools/calculate.rs +++ b/src-tauri/src/tools/calculate.rs @@ -127,9 +127,23 @@ fn operand_end(cs: &[char], mut i: usize) -> usize { while i < cs.len() && (cs[i].is_whitespace() || cs[i] == '-' || cs[i] == '+') { i += 1; } + let start = i; while i < cs.len() && (cs[i].is_alphanumeric() || cs[i] == '.' || cs[i] == '_') { i += 1; } + // A number's exponent carries its own sign (`1e-3`), which the scan + // above stops at — so `-1e-3` became `(0-1e)-3`. Take the signed + // exponent into the operand when what came before it is a mantissa. + if is_mantissa_and_e(&cs[start..i]) + && i + 1 < cs.len() + && matches!(cs[i], '-' | '+') + && cs[i + 1].is_ascii_digit() + { + i += 1; + while i < cs.len() && cs[i].is_ascii_digit() { + i += 1; + } + } let open = skip_spaces(cs, i); if open < cs.len() && cs[open] == '(' { let mut depth = 0usize; @@ -151,6 +165,19 @@ fn operand_end(cs: &[char], mut i: usize) -> usize { i } +/// Whether `run` is a number literal cut off right after its `e` / `E` — +/// digits and a dot, then the exponent marker — rather than an identifier +/// that happens to end in `e`. +fn is_mantissa_and_e(run: &[char]) -> bool { + match run.split_last() { + Some((&('e' | 'E'), mantissa)) => { + mantissa.iter().any(|c| c.is_ascii_digit()) + && mantissa.iter().all(|c| c.is_ascii_digit() || *c == '.') + } + _ => false, + } +} + /// End of an operand *plus* any `^` chain hanging off it, which is the extent /// a unary minus has to cover to bind looser than exponentiation. fn power_operand_end(cs: &[char], i: usize) -> usize { @@ -295,6 +322,15 @@ fn format_result(value: f64) -> String { } .to_string(); } + format_number(value) +} + +/// Print a finite `f64` for the model; `unit_convert` shares it so both +/// tools write numbers alike. Integer-valued results show as `42`, not +/// `42.0`; anything else gets 12 significant digits, written out for +/// ordinary magnitudes and in scientific notation (`5.1e-12`) for very +/// small or large ones, switching where C's `%g` does. +pub(super) fn format_number(value: f64) -> String { // Integer-valued floats render as integers. The 1e15 guard keeps us // inside the range where f64 can represent every integer exactly — // beyond that, displaying as "integer" would be a lie. @@ -302,18 +338,30 @@ fn format_result(value: f64) -> String { return format!("{}", value as i64); } // Default Display gives ~17 significant digits for round-trippability. - // That's noisy for chat output; trim to 12 significant digits which - // is still well beyond what the user can verify by hand. - let s = format!("{value:.12}"); - let trimmed = s.trim_end_matches('0').trim_end_matches('.'); - // `value` is non-zero here (an exact zero takes the integer branch - // above), but a tiny magnitude can still round away to "0" / "-0" at - // 12 decimals. Surface those in scientific notation so a real result - // like 1.2e-15 isn't reported as a flat "0". - if trimmed.is_empty() || trimmed == "-" || trimmed == "0" || trimmed == "-0" { - return format!("{value:e}"); - } - trimmed.to_string() + // That's noisy for chat output; 12 is still well beyond what the user + // can verify by hand. `{:.12}` gave 12 *decimal places* instead, so + // 5.1e-12 came out as 0.000000000005 and 1e17/3 as 33333333333333332. + // `{:.11e}` rounds to 12 significant digits and reports the exponent + // after rounding (9.99999999999999 → 1.00000000000e1), so it picks the + // layout. + let sci = format!("{value:.11e}"); + let (mantissa, exp) = sci.split_once('e').expect("`{:e}` always writes an exponent"); + let exp: i32 = exp.parse().expect("`{:e}` writes an integer exponent"); + if (-4..12).contains(&exp) { + trim_fraction(&format!("{value:.*}", (11 - exp) as usize)).to_string() + } else { + format!("{}e{exp}", trim_fraction(mantissa)) + } +} + +/// Drop the trailing zeros of a decimal fraction, and the point when +/// nothing is left after it. Text without a point is an integer — left alone. +fn trim_fraction(s: &str) -> &str { + if s.contains('.') { + s.trim_end_matches('0').trim_end_matches('.') + } else { + s + } } #[cfg(test)] @@ -468,11 +516,48 @@ mod tests { // through the float path rather than as an integer. let r = dispatch(&json!({"expression": "6.022e23 / 2"})); assert!(!r.is_error, "{}", r.content_text); - assert!(r.content_text.starts_with("301100000000"), "got: {}", r.content_text); + assert_eq!(r.content_text, "3.011e23"); // The `e` constant still resolves when it isn't part of a literal. assert_evaluates(&[("log(e)", "1"), ("e^0", "1")]); } + /// A literal's exponent sign is part of the literal, so a unary minus in + /// front of one must cover it: `-1e-3` used to become `(0-1e)-3`, + /// which exmex can't parse. + #[test] + fn unary_minus_covers_a_signed_exponent() { + assert_evaluates(&[ + ("-1e-3", "-0.001"), + ("-1e+3", "-1000"), + ("-1.602e-19", "-1.602e-19"), + ("2*-6.626e-34", "-1.3252e-33"), + ("abs(-1e-3)", "0.001"), + ("-2e-1^2", "-0.04"), + ("10/-2E-1", "-50"), + // The constant `e` isn't a mantissa: these minuses stay binary. + ("-e-1", "-3.71828182846"), + ("e-1", "1.71828182846"), + ]); + assert_eq!(normalize_expression("-1e-3"), "(0-1e-3)"); + let r = dispatch(&json!({"expression": "sqrt(-1e-3)"})); + assert!(!r.is_error && r.content_text.contains("NaN"), "{}", r.content_text); + } + + /// Results carry 12 significant digits, not 12 decimal places: a tiny + /// value keeps its digits and a huge one doesn't print f64 noise. + #[test] + fn results_keep_twelve_significant_digits() { + assert_eq!(format_number(5.1e-12), "5.1e-12"); + assert_eq!(format_number(1e17 / 3.0), "3.33333333333e16"); + assert_eq!(format_number(2.0 / 3.0), "0.666666666667"); + assert_eq!(format_number(0.1 + 0.2), "0.3"); + assert_eq!(format_number(0.000123456789), "0.000123456789"); + assert_eq!(format_number(123456.789), "123456.789"); + assert_eq!(format_number(1234567890123.5), "1.23456789012e12"); + assert_eq!(format_number(-9.99999999999999), "-10"); + assert_eq!(format_number(1e15), "1e15"); + } + /// The rewrites are textual, so they must leave non-ASCII identifiers and /// ordinary subtraction alone. #[test] diff --git a/src-tauri/src/tools/count.rs b/src-tauri/src/tools/count.rs index 30244d0..51ce75f 100644 --- a/src-tauri/src/tools/count.rs +++ b/src-tauri/src/tools/count.rs @@ -4,6 +4,7 @@ //! strawberry" failure. This tool just delegates to the standard library. use serde_json::{json, Value}; +use unicode_segmentation::UnicodeSegmentation; use crate::mcp::McpCallResult; @@ -19,7 +20,10 @@ pub fn tool_description() -> &'static str { in a string. Use this rather than estimating — tokenization hides the \ actual character count from you, and you will be wrong even on simple \ inputs like `strawberry`. Modes: \ - `chars` (Unicode scalar values), \ + `chars` (Unicode scalar values, not the characters a reader sees: an \ + emoji with a skin tone, a flag, or a letter built from a base and a \ + combining accent counts as 2 or more), \ + `graphemes` (the characters a reader sees — each of those counts as 1), \ `bytes` (UTF-8 byte length), \ `words` (whitespace-separated tokens), \ `lines` (count of `\\n`-separated lines, treating the final unterminated \ @@ -34,7 +38,7 @@ pub fn input_schema() -> Value { "input": { "type": "string", "description": "The string to count in." }, "mode": { "type": "string", - "enum": ["chars", "bytes", "words", "lines", "occurrences"], + "enum": ["chars", "graphemes", "bytes", "words", "lines", "occurrences"], "description": "What to count." }, "needle": { "type": "string", "description": "Substring to count for `occurrences`. Required in that mode." }, @@ -61,7 +65,10 @@ pub fn dispatch(args: &Value) -> McpCallResult { None => return err("missing required `mode` argument"), }; let n = match mode { + // Scalar values, as the description says; `graphemes` counts what a + // reader sees as one character (extended grapheme clusters). "chars" => input.chars().count(), + "graphemes" => input.graphemes(true).count(), "bytes" => input.len(), "words" => input.split_whitespace().count(), // `lines()` consumes one trailing `\n` if present and treats the @@ -125,6 +132,32 @@ mod tests { assert_eq!(r.content_text, "6"); } + /// `chars` counts scalar values, as the description warns: one visible + /// emoji with a skin tone, or one flag, is two of them. + #[test] + fn chars_counts_scalar_values_not_graphemes() { + for input in ["👍🏽", "🇵🇱"] { + let r = dispatch(&json!({"input": input, "mode": "chars"})); + assert_eq!(r.content_text, "2", "{input}"); + } + assert!(tool_description().contains("scalar values")); + } + + /// `graphemes` counts what a reader sees: a toned emoji, a flag and an + /// `é` built from `e` + U+0301 are one each, where `chars` says 2. + #[test] + fn graphemes_count_user_perceived_characters() { + for input in ["👍🏽", "🇵🇱", "e\u{301}"] { + let r = dispatch(&json!({"input": input, "mode": "graphemes"})); + assert!(!r.is_error, "{}", r.content_text); + assert_eq!(r.content_text, "1", "{input}"); + let r = dispatch(&json!({"input": input, "mode": "chars"})); + assert_eq!(r.content_text, "2", "{input}"); + } + let r = dispatch(&json!({"input": "Café 👍🏽🇵🇱", "mode": "graphemes"})); + assert_eq!(r.content_text, "7"); + } + #[test] fn counts_words_and_lines() { let r = dispatch(&json!({"input": "one two\nthree four", "mode": "words"})); diff --git a/src-tauri/src/tools/datetime.rs b/src-tauri/src/tools/datetime.rs index 0f7ee78..3c40319 100644 --- a/src-tauri/src/tools/datetime.rs +++ b/src-tauri/src/tools/datetime.rs @@ -14,7 +14,7 @@ use chrono::format::{Item, StrftimeItems}; use chrono::{ DateTime, Datelike, Days, LocalResult, Months, NaiveDate, NaiveDateTime, TimeZone, Utc, Weekday, }; -use chrono_tz::{Tz, UTC}; +use chrono_tz::Tz; use serde_json::{json, Value}; use crate::mcp::McpCallResult; @@ -48,17 +48,24 @@ pub fn tool_description() -> &'static str { `format` (render a datetime with a strftime pattern), \ `add` (add or subtract a unit — use negative amount to subtract; \ calendar units `years`, `months`, `weeks`, `days`, `business_days` \ - preserve wall-clock time across DST boundaries; duration units \ - `hours`, `minutes`, `seconds` preserve absolute elapsed time and \ - may shift the wall clock across DST), \ - `diff` (difference between two datetimes in a chosen unit), \ - `tz_convert` (convert a datetime to a target timezone, DST-aware), \ + preserve wall-clock time across DST boundaries — a result in a \ + repeated DST hour takes the earlier one, one in a skipped hour is an \ + error; duration units `hours`, `minutes`, `seconds` preserve absolute \ + elapsed time and may shift the wall clock across DST), \ + `diff` (whole units elapsed between two datetimes, negative when `to` \ + is earlier; `years` / `months` count a period once the same day of \ + the month comes round, or the last day of a shorter month — Jan 31 → \ + Feb 28 is 1 month, Jan 15 → Feb 14 is 0 — and `days` / `weeks` count \ + wall-clock days in `timezone`, like `add`), \ + `tz_convert` (convert a datetime to the target `timezone`, DST-aware; \ + an input without offset is read in `source_timezone`, default UTC), \ `weekday` (weekday name for a date), \ `business_days_from` (N business days forward or back from a date, \ skipping Saturday and Sunday — does not account for holidays). \ Datetime strings must be RFC 3339 (`2026-05-25T14:30:00Z`, \ `2026-05-25T14:30:00+02:00`), ISO-8601 without offset \ - (`2026-05-25T14:30:00` — interpreted in `timezone` or UTC), or date \ + (`2026-05-25T14:30:00` — interpreted in `timezone`, default UTC; for \ + `tz_convert`, in `source_timezone`), or date \ only (`2026-05-25`). Timezones are IANA names like \ `Europe/Warsaw` or `America/New_York`. Units for add/diff: \ `years`, `months`, `weeks`, `days`, `hours`, `minutes`, `seconds`, \ @@ -81,6 +88,7 @@ pub fn input_schema() -> Value { "from": { "type": "string", "description": "Source datetime for `diff`." }, "to": { "type": "string", "description": "Target datetime for `diff`." }, "timezone": { "type": "string", "description": "IANA timezone. For `now`, `parse`, `format`, `weekday`, `add`: the display tz and the tz a naïve input is interpreted in. For `tz_convert`: the target tz." }, + "source_timezone": { "type": "string", "description": "IANA timezone a naïve `input` is interpreted in for `tz_convert`. Defaults to UTC; ignored when `input` carries an offset." }, "unit": { "type": "string", "enum": ["years", "months", "weeks", "days", "hours", "minutes", "seconds", "business_days"], @@ -264,11 +272,15 @@ fn op_tz_convert(args: &Value) -> McpCallResult { Ok(tz) => tz, Err(e) => return err(e), }; - // Naïve inputs to tz_convert are interpreted in UTC — anything else - // would silently misinterpret strings like `2026-05-25T14:30:00` as - // wall-clock time in the *target* tz, which is the bug we're trying - // to prevent. The model should add an offset for unambiguous input. - let dt = match parse_input(input, &UTC) { + // Naïve inputs to tz_convert are interpreted in `source_timezone` (UTC + // when absent), never in the *target* tz — that would silently read + // `2026-05-25T14:30:00` as wall-clock time where it's being converted + // to, which is the bug we're trying to prevent. + let source_tz = match resolve_tz(args.get("source_timezone").and_then(|v| v.as_str())) { + Ok(tz) => tz, + Err(e) => return err(e), + }; + let dt = match parse_input(input, &source_tz) { Ok(dt) => dt, Err(e) => return err(e), }; @@ -340,16 +352,34 @@ fn parse_input(s: &str, fallback_tz: &Tz) -> Result<DateTime<Utc>, String> { } } if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { - let naive = date - .and_hms_opt(0, 0, 0) - .expect("00:00:00 is always a valid time"); - return resolve_naive(naive, fallback_tz); + return start_of_day(date, fallback_tz); } Err(format!( "could not parse `{s}` as a datetime (try RFC 3339, ISO 8601, or `YYYY-MM-DD`)" )) } +/// The first instant of `date` in `tz`, which a date given alone stands for. +/// That's midnight almost everywhere, but where the clocks spring forward at +/// 00:00 (Africa/Cairo, America/Santiago, Havana, Asunción) midnight doesn't +/// exist, and a bare date there failed as a DST gap: the day starts where the +/// gap ends instead. Clocks change on whole minutes (bar a few local-mean-time +/// switches before 1972), so the first minute that exists is that start. +fn start_of_day(date: NaiveDate, tz: &Tz) -> Result<DateTime<Utc>, String> { + for minute in 0..24 * 60 { + let naive = date + .and_hms_opt(minute / 60, minute % 60, 0) + .expect("a minute of the day is always a valid time"); + // A repeated midnight takes its earlier occurrence, as in `resolve_naive`. + if let Some(dt) = tz.from_local_datetime(&naive).earliest() { + return Ok(dt.with_timezone(&Utc)); + } + } + Err(format!( + "date `{date}` does not exist in timezone `{tz}` — its clocks skipped the whole day" + )) +} + fn resolve_naive(naive: NaiveDateTime, tz: &Tz) -> Result<DateTime<Utc>, String> { match tz.from_local_datetime(&naive) { LocalResult::Single(dt) => Ok(dt.with_timezone(&Utc)), @@ -383,17 +413,17 @@ fn add_unit(start: DateTime<Utc>, tz: &Tz, unit: &str, amount: i64) -> Result<Da "years" => { let months = mag.checked_mul(12).ok_or_else(|| "years × 12 overflowed".to_string())?; let months = u32::try_from(months).map_err(|_| "year amount too large for chrono".to_string())?; - apply_months_local(local, months, neg) + apply_months_local(local, tz, months, neg) } "months" => { let months = u32::try_from(mag).map_err(|_| "month amount too large for chrono".to_string())?; - apply_months_local(local, months, neg) + apply_months_local(local, tz, months, neg) } "weeks" => { let days = mag.checked_mul(7).ok_or_else(|| "weeks × 7 overflowed".to_string())?; - apply_days_local(local, days, neg) + apply_days_local(local, tz, days, neg) } - "days" => apply_days_local(local, mag, neg), + "days" => apply_days_local(local, tz, mag, neg), "hours" => { let secs = (mag as i64).checked_mul(3600).ok_or_else(|| "hours × 3600 overflowed".to_string())?; apply_seconds(start, secs, neg) @@ -411,39 +441,49 @@ fn add_unit(start: DateTime<Utc>, tz: &Tz, unit: &str, amount: i64) -> Result<Da let signed = if neg { -(mag as i64) } else { mag as i64 }; let new_date = add_business_days(date, signed) .ok_or_else(|| "business-day arithmetic overflowed".to_string())?; - let naive = new_date.and_time(local.time()); - match tz.from_local_datetime(&naive) { - LocalResult::Single(dt) => Ok(dt.with_timezone(&Utc)), - LocalResult::Ambiguous(early, _) => Ok(early.with_timezone(&Utc)), - LocalResult::None => Err(format!( - "result lands on a DST gap in `{tz}` ({naive})" - )), - } + shifted_to_utc(new_date.and_time(local.time()), tz) } other => Err(format!("unknown unit `{other}`")), } } -fn apply_months_local(local: DateTime<Tz>, months: u32, neg: bool) -> Result<DateTime<Utc>, String> { +/// The instant a calendar shift lands on, from its local wall-clock time: +/// an hour the fall-back repeats resolves to its earlier occurrence, and +/// one the spring-forward skips is an error that says so. chrono's own +/// `DateTime::checked_add_*` give up on both, which surfaced as a +/// misleading "outside chrono's supported range". +fn shifted_to_utc(naive: NaiveDateTime, tz: &Tz) -> Result<DateTime<Utc>, String> { + match tz.from_local_datetime(&naive) { + LocalResult::Single(dt) => Ok(dt.with_timezone(&Utc)), + LocalResult::Ambiguous(early, _) => Ok(early.with_timezone(&Utc)), + LocalResult::None => Err(format!( + "result lands on a DST gap in `{tz}` ({naive})" + )), + } +} + +fn apply_months_local(local: DateTime<Tz>, tz: &Tz, months: u32, neg: bool) -> Result<DateTime<Utc>, String> { let m = Months::new(months); + let naive = local.naive_local(); let out = if neg { - local.checked_sub_months(m) + naive.checked_sub_months(m) } else { - local.checked_add_months(m) + naive.checked_add_months(m) }; - out.map(|d| d.with_timezone(&Utc)) - .ok_or_else(|| "result is outside chrono's supported range".to_string()) + let shifted = out.ok_or_else(|| "result is outside chrono's supported range".to_string())?; + shifted_to_utc(shifted, tz) } -fn apply_days_local(local: DateTime<Tz>, days: u64, neg: bool) -> Result<DateTime<Utc>, String> { +fn apply_days_local(local: DateTime<Tz>, tz: &Tz, days: u64, neg: bool) -> Result<DateTime<Utc>, String> { let d = Days::new(days); + let naive = local.naive_local(); let out = if neg { - local.checked_sub_days(d) + naive.checked_sub_days(d) } else { - local.checked_add_days(d) + naive.checked_add_days(d) }; - out.map(|d| d.with_timezone(&Utc)) - .ok_or_else(|| "result is outside chrono's supported range".to_string()) + let shifted = out.ok_or_else(|| "result is outside chrono's supported range".to_string())?; + shifted_to_utc(shifted, tz) } fn apply_seconds(start: DateTime<Utc>, secs: i64, neg: bool) -> Result<DateTime<Utc>, String> { @@ -464,17 +504,17 @@ fn diff_unit(from: DateTime<Utc>, to: DateTime<Utc>, tz: &Tz, unit: &str) -> Res // are frame-independent, so those keep using the UTC instants directly. let from_local = from.with_timezone(tz); let to_local = to.with_timezone(tz); + // Calendar units count whole periods elapsed on the local wall clock, + // the inverse of `add`: what `add` would reach from `from` without + // passing `to`. Subtracting the year/month fields instead counted + // boundaries crossed (Dec 31 → Jan 1 was a year), and an absolute + // day count came up an hour short of every day a spring-forward is in. + let wall_clock = to_local.naive_local() - from_local.naive_local(); match unit { - // Calendar-based: subtract fields, not durations, so "Jan 31 → Feb 1" - // is 1 month rather than ~0.97 of a month. - "years" => Ok(to_local.year() as i64 - from_local.year() as i64), - "months" => { - let y = to_local.year() as i64 - from_local.year() as i64; - let m = to_local.month() as i64 - from_local.month() as i64; - Ok(y * 12 + m) - } - "weeks" => Ok(to.signed_duration_since(from).num_weeks()), - "days" => Ok(to.signed_duration_since(from).num_days()), + "years" => Ok(whole_months_between(from_local.naive_local(), to_local.naive_local())? / 12), + "months" => whole_months_between(from_local.naive_local(), to_local.naive_local()), + "weeks" => Ok(wall_clock.num_weeks()), + "days" => Ok(wall_clock.num_days()), "hours" => Ok(to.signed_duration_since(from).num_hours()), "minutes" => Ok(to.signed_duration_since(from).num_minutes()), "seconds" => Ok(to.signed_duration_since(from).num_seconds()), @@ -498,6 +538,25 @@ fn diff_unit(from: DateTime<Utc>, to: DateTime<Utc>, tz: &Tz, unit: &str) -> Res } } +/// Whole months from `from` to `to`: the most `add` can move `from` forward +/// without passing `to`, so a month counts once the same day of the month +/// comes round — or the last day of a month too short to have it (Jan 31 → +/// Feb 28 is one month). Negative when `to` is earlier, by the size of the +/// span the other way round. +fn whole_months_between(from: NaiveDateTime, to: NaiveDateTime) -> Result<i64, String> { + if to < from { + return whole_months_between(to, from).map(|n| -n); + } + let crossed = (to.year() as i64 - from.year() as i64) * 12 + to.month() as i64 - from.month() as i64; + // `crossed` lands in `to`'s own month, so it's either right or one too + // many — when that month's day and time haven't come round yet. + let reached = u32::try_from(crossed) + .ok() + .and_then(|n| from.checked_add_months(Months::new(n))) + .ok_or_else(|| "date span is outside chrono's supported range".to_string())?; + Ok(if reached > to { crossed - 1 } else { crossed }) +} + /// Add `n` business days (Mon–Fri) to `date`. Negative `n` walks backward. /// Iterative because there's no fast closed-form once you respect the start /// weekday — but capped by [`MAX_AMOUNT_ABS`] in the public ops. @@ -517,32 +576,28 @@ fn add_business_days(mut date: NaiveDate, n: i64) -> Option<NaiveDate> { Some(date) } -/// Count business days between two dates (sign preserved). `from == to` -/// is zero; same-week Mon→Fri is 4. +/// Count business days between two dates (sign preserved): the weekdays +/// after the earlier date up to and including the later one, negative when +/// `to` is the earlier. `from == to` is zero; same-week Mon→Fri is 4. +/// Swapping the ends only flips the sign — walking backward used to count +/// the days it walked *to*, so with a weekend at one end Sat→Mon was 1 but +/// Mon→Sat 0. fn business_days_between(from: NaiveDate, to: NaiveDate) -> i64 { - if from == to { - return 0; + if to < from { + return -business_days_between(to, from); } - let step: i64 = if to > from { 1 } else { -1 }; let mut cur = from; let mut count: i64 = 0; while cur != to { - cur = if step > 0 { - match cur.succ_opt() { - Some(d) => d, - None => return count * step, - } - } else { - match cur.pred_opt() { - Some(d) => d, - None => return count * step, - } + cur = match cur.succ_opt() { + Some(d) => d, + None => return count, }; if !is_weekend(cur.weekday()) { count += 1; } } - count * step + count } fn is_weekend(w: Weekday) -> bool { @@ -668,16 +723,54 @@ mod tests { assert_eq!(r.content_text, "5"); } + fn diff(from: &str, to: &str, unit: &str, tz: &str) -> String { + let r = dispatch(&json!({ "op": "diff", "from": from, "to": to, "unit": unit, "timezone": tz })); + assert!(!r.is_error, "{from} → {to} in {unit}: {}", r.content_text); + r.content_text + } + + /// Calendar diffs count whole periods elapsed, not boundaries crossed: + /// someone born on 1990-12-31 is 35 on 2026-01-01, not 36. #[test] - fn diff_months_is_calendar_based() { - let r = dispatch(&json!({ - "op": "diff", - "from": "2026-01-31", - "to": "2026-02-01", - "unit": "months", - })); - assert!(!r.is_error); - assert_eq!(r.content_text, "1"); + fn diff_years_and_months_count_whole_periods() { + assert_eq!(diff("1990-12-31", "2026-01-01", "years", "UTC"), "35"); + assert_eq!(diff("1990-12-31", "2025-12-31", "years", "UTC"), "35"); + assert_eq!(diff("1990-12-31", "2025-12-30", "years", "UTC"), "34"); + assert_eq!(diff("2026-01-15", "2026-02-14", "months", "UTC"), "0"); + assert_eq!(diff("2026-01-15", "2026-02-15", "months", "UTC"), "1"); + assert_eq!(diff("2026-01-31", "2026-02-01", "months", "UTC"), "0"); + // The time of day counts too: the month isn't up until 10:00. + assert_eq!(diff("2026-01-15T10:00:00", "2026-02-15T09:59:00", "months", "UTC"), "0"); + // Reversed spans are negative, with the same magnitude. + assert_eq!(diff("2026-01-01", "1990-12-31", "years", "UTC"), "-35"); + assert_eq!(diff("2026-02-14", "2026-01-15", "months", "UTC"), "0"); + } + + /// The end-of-month convention matches `add`, which clamps Jan 31 + 1 + /// month to Feb 28: reaching the last day of a shorter month counts. + #[test] + fn diff_months_counts_a_short_months_last_day() { + assert_eq!(diff("2026-01-31", "2026-02-28", "months", "UTC"), "1"); + assert_eq!(diff("2026-01-31", "2026-02-27", "months", "UTC"), "0"); + assert_eq!(diff("2026-01-31", "2026-03-30", "months", "UTC"), "1"); + assert_eq!(diff("2026-01-31", "2026-03-31", "months", "UTC"), "2"); + assert_eq!(diff("2028-02-29", "2029-02-28", "years", "UTC"), "1"); + let added = dispatch(&json!({ "op": "add", "input": "2026-01-31", "unit": "months", "amount": 1 })); + assert_eq!(added.content_text, "2026-02-28T00:00:00+00:00"); + } + + /// `days` and `weeks` count wall-clock days in `timezone`, as `add` + /// moves by them: a span holding the spring-forward has a 23-hour day + /// in it, and an absolute count came up one short. + #[test] + fn diff_days_and_weeks_follow_the_wall_clock_across_dst() { + assert_eq!(diff("2026-03-01", "2026-04-01", "days", "Europe/Warsaw"), "31"); + assert_eq!(diff("2026-03-02", "2026-03-30", "weeks", "Europe/Warsaw"), "4"); + assert_eq!(diff("2026-04-01", "2026-03-01", "days", "Europe/Warsaw"), "-31"); + // Less than a whole day of wall-clock time is still zero days. + assert_eq!(diff("2026-05-25T10:00:00", "2026-05-26T09:00:00", "days", "UTC"), "0"); + // Hours stay absolute: that March has one hour fewer. + assert_eq!(diff("2026-03-01", "2026-04-01", "hours", "Europe/Warsaw"), "743"); } #[test] @@ -698,17 +791,40 @@ mod tests { #[test] fn diff_months_uses_the_requested_timezone() { - // 20:00 on Jan 31 in New York is already Feb 1 in UTC, so the UTC - // reading collapsed a month boundary the caller straddles. + // 20:00 on Jan 31 in New York is already Feb 1 in UTC, and the + // clocks spring forward before Mar 31: read in UTC, the two-month + // mark fell at 21:00 local and this span came out as one month. let r = dispatch(&json!({ "op": "diff", "from": "2026-01-31T20:00:00-05:00", - "to": "2026-02-01T10:00:00-05:00", + "to": "2026-03-31T20:30:00-04:00", "timezone": "America/New_York", "unit": "months", })); assert!(!r.is_error, "{}", r.content_text); - assert_eq!(r.content_text, "1"); + assert_eq!(r.content_text, "2"); + } + + /// A calendar add that lands in the hour the fall-back repeats takes its + /// earlier occurrence, as the business-day path did; one that lands in + /// the skipped spring-forward hour says so. chrono's own `checked_add_*` + /// refused both as "outside chrono's supported range". + #[test] + fn calendar_adds_resolve_dst_ambiguity_and_name_the_gap() { + for (input, unit) in [("2026-09-25T02:30:00", "months"), ("2026-10-24T02:30:00", "days")] { + let r = dispatch(&json!({ + "op": "add", "input": input, "timezone": "Europe/Warsaw", "unit": unit, "amount": 1, + })); + assert!(!r.is_error, "{unit}: {}", r.content_text); + assert_eq!(r.content_text, "2026-10-25T02:30:00+02:00", "{unit}"); + } + for (input, unit) in [("2025-03-29T02:30:00", "years"), ("2026-03-22T02:30:00", "weeks")] { + let r = dispatch(&json!({ + "op": "add", "input": input, "timezone": "Europe/Warsaw", "unit": unit, "amount": 1, + })); + assert!(r.is_error, "{unit}: {}", r.content_text); + assert!(r.content_text.contains("DST gap"), "{unit}: {}", r.content_text); + } } #[test] @@ -737,6 +853,40 @@ mod tests { assert!(r.content_text.starts_with("2026-07-04T08:00:00-04:00"), "got: {}", r.content_text); } + /// An offset-less input is read in `source_timezone` — UTC without one, + /// never the target `timezone` — and an explicit offset wins over it. + #[test] + fn tz_convert_reads_a_naive_input_in_the_source_timezone() { + let convert = |args: Value| { + let r = dispatch(&args); + assert!(!r.is_error, "{}", r.content_text); + r.content_text + }; + assert_eq!( + convert(json!({ + "op": "tz_convert", "input": "2026-07-04T09:00:00", + "source_timezone": "Europe/Warsaw", "timezone": "America/New_York", + })), + "2026-07-04T03:00:00-04:00" + ); + assert_eq!( + convert(json!({ "op": "tz_convert", "input": "2026-07-04T09:00:00", "timezone": "America/New_York" })), + "2026-07-04T05:00:00-04:00" + ); + assert_eq!( + convert(json!({ + "op": "tz_convert", "input": "2026-07-04T09:00:00Z", + "source_timezone": "Europe/Warsaw", "timezone": "America/New_York", + })), + "2026-07-04T05:00:00-04:00" + ); + let bad = dispatch(&json!({ + "op": "tz_convert", "input": "2026-07-04T09:00:00", + "source_timezone": "Middle/Earth", "timezone": "UTC", + })); + assert!(bad.is_error && bad.content_text.contains("unknown timezone"), "{}", bad.content_text); + } + #[test] fn unknown_op_is_an_error() { let r = dispatch(&json!({"op": "teleport"})); @@ -804,4 +954,38 @@ mod tests { 4 ); } + + /// Reversing a span only flips the sign, a weekend end included. + #[test] + fn business_days_diff_is_antisymmetric() { + // 2026-05-30 is a Saturday, 2026-06-01 the Monday after it. + for (from, to, want) in [ + ("2026-05-30", "2026-06-01", "1"), + ("2026-06-01", "2026-05-30", "-1"), + ("2026-05-29", "2026-05-31", "0"), + ("2026-05-31", "2026-05-29", "0"), + ("2026-05-25", "2026-06-06", "9"), + ("2026-06-06", "2026-05-25", "-9"), + ] { + assert_eq!(diff(from, to, "business_days", "UTC"), want, "{from} → {to}"); + } + } + + /// Egypt springs forward at midnight, so 2026-04-24 has no 00:00 in + /// Cairo: a bare date there is the first minute it does have. + #[test] + fn a_date_starts_after_a_midnight_dst_gap() { + let r = dispatch(&json!({ "op": "parse", "input": "2026-04-24", "timezone": "Africa/Cairo" })); + assert!(!r.is_error, "{}", r.content_text); + assert_eq!(r.content_text, "2026-04-24T01:00:00+03:00"); + let r = dispatch(&json!({ "op": "weekday", "input": "2026-04-24", "timezone": "Africa/Cairo" })); + assert_eq!(r.content_text, "Friday"); + // An ordinary midnight is still midnight. + let r = dispatch(&json!({ "op": "parse", "input": "2026-04-23", "timezone": "Africa/Cairo" })); + assert_eq!(r.content_text, "2026-04-23T00:00:00+02:00"); + // Samoa skipped 2011-12-30 outright when it crossed the date line. + let r = dispatch(&json!({ "op": "parse", "input": "2011-12-30", "timezone": "Pacific/Apia" })); + assert!(r.is_error, "{}", r.content_text); + assert!(r.content_text.contains("skipped the whole day"), "{}", r.content_text); + } } diff --git a/src-tauri/src/tools/diff_text.rs b/src-tauri/src/tools/diff_text.rs index dd0fae5..d0b7fb2 100644 --- a/src-tauri/src/tools/diff_text.rs +++ b/src-tauri/src/tools/diff_text.rs @@ -92,7 +92,9 @@ pub fn dispatch(args: &Value) -> McpCallResult { "char" => inline_diff(TextDiff::configure().timeout(DIFF_TIMEOUT).diff_chars(a, b)), other => return err(format!("unknown mode `{other}`")), }; - if out.trim().is_empty() { + // Word and char modes render shared text too, so identical inputs came + // back whole rather than as line mode's `(no differences)`. + if a == b || out.trim().is_empty() { return McpCallResult { content_text: "(no differences)".to_string(), is_error: false, @@ -167,9 +169,11 @@ mod tests { #[test] fn identical_inputs_report_no_differences() { - let r = dispatch(&json!({"a": "same\nstuff\n", "b": "same\nstuff\n"})); - assert!(!r.is_error); - assert_eq!(r.content_text, "(no differences)"); + for mode in ["line", "word", "char"] { + let r = dispatch(&json!({"a": "same\nstuff\n", "b": "same\nstuff\n", "mode": mode})); + assert!(!r.is_error); + assert_eq!(r.content_text, "(no differences)", "{mode}"); + } } #[test] diff --git a/src-tauri/src/tools/fetch_url.rs b/src-tauri/src/tools/fetch_url.rs index 7d6a68a..2fc97c1 100644 --- a/src-tauri/src/tools/fetch_url.rs +++ b/src-tauri/src/tools/fetch_url.rs @@ -106,6 +106,12 @@ pub struct FetchedPage { /// to close the TOCTOU window between "we resolved a public IP" and /// "reqwest dials a now-private one". pub async fn fetch(_shared_http: &reqwest::Client, raw_url: &str) -> Result<FetchedPage, String> { + fetch_capped(raw_url, MAX_TEXT_CHARS).await +} + +/// [`fetch`] with a caller-chosen text cap. The Library keeps whole pages +/// (it chunks them for retrieval), where a prompt prefetch wants a short one. +pub async fn fetch_capped(raw_url: &str, max_text_chars: usize) -> Result<FetchedPage, String> { // Rate-limit before URL parsing so even an invalid-URL flood is bounded. // The slot is consumed regardless of fetch success — a renderer that // keeps firing fetches against malformed URLs still gets throttled. @@ -144,15 +150,21 @@ pub async fn fetch(_shared_http: &reqwest::Client, raw_url: &str) -> Result<Fetc // per-request client. Reqwest will use this map instead of re- // resolving before it dials, so a hostname can't flip from public → // private between our check and the actual connection. - let resolved = resolve_safe_addrs(&url).await?; + // + // The lookup spends the same budget: checked only after it, a + // resolver that never answered held each hop as long as the OS let it. + let over_budget = || format!("Fetch exceeded its {}s time budget", FETCH_TIMEOUT.as_secs()); + let resolved = tokio::time::timeout( + deadline.saturating_duration_since(Instant::now()), + resolve_safe_addrs(&url), + ) + .await + .map_err(|_| over_budget())??; let http = build_pinned_client(&url, &resolved)?; let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { - return Err(format!( - "Fetch exceeded its {}s time budget", - FETCH_TIMEOUT.as_secs() - )); + return Err(over_budget()); } let resp = http .get(url.clone()) @@ -221,7 +233,7 @@ pub async fn fetch(_shared_http: &reqwest::Client, raw_url: &str) -> Result<Fetc } let bytes = buf.len(); - let body = String::from_utf8_lossy(&buf).to_string(); + let body = decode_body(&buf, &content_type); let (title, text_raw) = if content_type.contains("html") || looks_like_html(&body) { html_to_text(&body) @@ -229,7 +241,7 @@ pub async fn fetch(_shared_http: &reqwest::Client, raw_url: &str) -> Result<Fetc (None, body) }; - let (text, truncated_text) = truncate(&text_raw, MAX_TEXT_CHARS); + let (text, truncated_text) = truncate(&text_raw, max_text_chars); Ok(FetchedPage { url: raw_url.to_string(), @@ -408,7 +420,13 @@ pub(crate) fn build_pinned_client(url: &Url, addrs: &[SocketAddr]) -> Result<req // back to the system resolver, bypassing the SSRF guard. `fetch()` // walks the redirect chain manually and rebuilds a pinned client // per hop. - .redirect(reqwest::redirect::Policy::none()); + .redirect(reqwest::redirect::Policy::none()) + // Connect directly, never through a proxy. reqwest otherwise picks + // one up from HTTP(S)_PROXY / ALL_PROXY or the OS settings, and a + // proxy resolves the hostname itself — the `resolve()` pin below is + // never consulted, so a rebinding host could reach a private + // address through it after passing our screen. + .no_proxy(); // `resolve()` plumbs a (host, addr) override into the internal resolver. // We feed in every screened address so a multi-A-record host can still @@ -484,6 +502,31 @@ pub fn is_public_ip(ip: &IpAddr) -> bool { if (segs[0] & 0xffc0) == 0xfe80 { return false; } + // fec0::/10 — site-local: deprecated, but where it's still used + // it's a private range like fc00::/7. + if (segs[0] & 0xffc0) == 0xfec0 { + return false; + } + // 2001:db8::/32 (documentation, the twin of the v4 ranges + // `is_documentation()` refuses) and 100::/64 (discard-only, + // RFC 6666) name no real host. + if (segs[0] == 0x2001 && segs[1] == 0x0db8) || segs[..4] == [0x0100, 0, 0, 0] { + return false; + } + // Tunnels and translators that pass the packet on to an IPv4 + // address carried inside the IPv6 one, which an AAAA record could + // aim at a private host: 6to4 (2002::/16), Teredo (2001::/32), + // local-use NAT64 (64:ff9b:1::/48) and IPv4-translated addresses + // (::ffff:0:0:0/96). All are obsolete or site-local, so refusing + // them outright loses no public host, where decoding each layout + // would be more to get wrong. + if segs[0] == 0x2002 + || (segs[0] == 0x2001 && segs[1] == 0) + || (segs[0] == 0x0064 && segs[1] == 0xff9b && segs[2] == 0x0001) + || (segs[..4] == [0, 0, 0, 0] && segs[4] == 0xffff && segs[5] == 0) + { + return false; + } // Helper: rebuild the embedded IPv4 from the low 32 bits and run // it back through the v4 screen. let embedded_v4 = |segs: &[u16; 8]| { @@ -537,12 +580,12 @@ pub fn is_public_ip(ip: &IpAddr) -> bool { } /// Returns `true` only for link-local addresses — IPv4 169.254.0.0/16, IPv6 -/// fe80::/10, and the IPv4-mapped / NAT64 / IPv4-compatible IPv6 forms that -/// smuggle a link-local IPv4 (e.g. `::ffff:169.254.169.254`). This is the -/// narrow "cloud-metadata" screen shared by the MCP resolver -/// ([`resolve_lan_addrs`]) and the LLM-provider guard -/// (`providers::refuse_link_local_host`) — one source of truth, distinct from -/// the all-private-ranges screen in [`is_public_ip`]. +/// fe80::/10, and the IPv6 forms that smuggle a link-local IPv4 (IPv4-mapped, +/// -translated and -compatible, NAT64, 6to4 and Teredo — e.g. +/// `::ffff:169.254.169.254`). This is the narrow "cloud-metadata" screen +/// shared by the MCP resolver ([`resolve_lan_addrs`]) and the LLM-provider +/// guard (`providers::refuse_link_local_host`) — one source of truth, distinct +/// from the all-private-ranges screen in [`is_public_ip`]. pub(crate) fn is_link_local(ip: &IpAddr) -> bool { match ip { IpAddr::V4(v4) => v4.is_link_local(), @@ -580,7 +623,9 @@ pub(crate) fn is_link_local(ip: &IpAddr) -> bool { && segs[3] == 0 && segs[4] == 0 && segs[5] == 0; - if is_v4_mapped || is_nat64 || is_v4_compat { + // ::ffff:0:a.b.c.d — IPv4-translated. + let is_v4_translated = segs[..4] == [0, 0, 0, 0] && segs[4] == 0xffff && segs[5] == 0; + if is_v4_mapped || is_nat64 || is_v4_compat || is_v4_translated { let embedded = std::net::Ipv4Addr::new( (segs[6] >> 8) as u8, (segs[6] & 0xff) as u8, @@ -589,11 +634,85 @@ pub(crate) fn is_link_local(ip: &IpAddr) -> bool { ); return embedded.is_link_local(); } + // The tunnel and translator forms `is_public_ip` refuses outright + // carry an IPv4 the packet is handed on to as well. This screen + // lets LAN and public hosts through, so it decodes that IPv4 + // instead of refusing the whole range: a LAN host behind + // local-use NAT64 is legitimate here, only 169.254.x.x is not. + let o = v6.octets(); + let link_local_at = |at: [usize; 4]| { + std::net::Ipv4Addr::new(o[at[0]], o[at[1]], o[at[2]], o[at[3]]).is_link_local() + }; + // 6to4 (2002::/16): the site router's IPv4 follows the prefix. + if segs[0] == 0x2002 { + return link_local_at([2, 3, 4, 5]); + } + // Teredo (2001::/32): the server's IPv4, then the client's with + // every bit inverted (RFC 4380) — traffic goes to both. + if segs[0] == 0x2001 && segs[1] == 0 { + let client = std::net::Ipv4Addr::new(!o[12], !o[13], !o[14], !o[15]); + return link_local_at([4, 5, 6, 7]) || client.is_link_local(); + } + // Local-use NAT64 (64:ff9b:1::/48): the operator picks the prefix + // length, which moves the IPv4 (RFC 6052 §2.2, skipping the + // reserved byte 8), so try each layout a /48 leaves room for. + if segs[..3] == [0x0064, 0xff9b, 0x0001] { + return [[6, 7, 9, 10], [7, 9, 10, 11], [9, 10, 11, 12], [12, 13, 14, 15]] + .into_iter() + .any(link_local_at); + } false } } } +/// The body as text, in the charset the page declares — the `Content-Type` +/// header's, else an HTML `<meta charset>` near the top — and UTF-8 when it +/// declares none. Read as UTF-8 regardless, a page served as ISO-8859-2 or +/// windows-1250 came out full of replacement characters, and a Library link +/// keeps that text for good. A byte-order mark wins over both. +fn decode_body(buf: &[u8], content_type: &str) -> String { + let encoding = match charset_param(content_type) { + Some(label) => encoding_rs::Encoding::for_label(label.as_bytes()), + // As the HTML spec's prescan reads a `<meta>`: one declaring UTF-16 + // means UTF-8 — the page that spelt it out in ASCII can't be UTF-16 — + // and a "replacement" label (`iso-2022-kr`) must not collapse the + // whole page into one U+FFFD. `output_encoding` maps both to UTF-8. + None => meta_charset(buf) + .and_then(|label| encoding_rs::Encoding::for_label(label.as_bytes())) + .map(encoding_rs::Encoding::output_encoding), + } + .unwrap_or(encoding_rs::UTF_8); + encoding.decode(buf).0.into_owned() +} + +/// `charset=` from a `Content-Type` value like `text/html; charset="utf-8"`. +fn charset_param(content_type: &str) -> Option<String> { + content_type.split(';').skip(1).find_map(|p| { + let (k, v) = p.split_once('=')?; + k.trim() + .eq_ignore_ascii_case("charset") + .then(|| v.trim().trim_matches(['"', '\'']).to_string()) + }) +} + +/// The charset an HTML page names in its first 1 KiB — `<meta charset=…>` or +/// a `http-equiv` Content-Type — where the HTML spec's prescan looks. Only +/// inside a `<meta>` tag: a `charset=` in the page's text or in another tag +/// declares nothing. +fn meta_charset(buf: &[u8]) -> Option<String> { + let head = String::from_utf8_lossy(&buf[..buf.len().min(1024)]).to_ascii_lowercase(); + head.match_indices("<meta").find_map(|(at, _)| { + let tag = &head[at..]; + let tag = &tag[..tag.find('>').unwrap_or(tag.len())]; + let rest = tag[tag.find("charset=")? + "charset=".len()..].trim_start_matches(['"', '\'']); + let end = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))) + .unwrap_or(rest.len()); + Some(rest[..end].to_string()).filter(|l| !l.is_empty()) + }) +} + fn looks_like_html(body: &str) -> bool { // Walk by chars rather than bytes so a non-ASCII glyph straddling the // 2048-byte mark doesn't panic with `byte index N is not a char boundary`. @@ -703,7 +822,7 @@ fn strip_blocks_with_lower( // whole pass O(n·k). let mut next_open: Vec<Option<usize>> = blocks_l .iter() - .map(|(open_l, _, _)| lower.find(open_l.as_str())) + .map(|(open_l, _, _)| find_opener(lower, open_l, 0)) .collect(); let mut out = String::with_capacity(hay.len()); @@ -715,7 +834,7 @@ fn strip_blocks_with_lower( let mut best: Option<(usize, usize)> = None; // (opener_pos, block index) for (idx, (open_l, _, _)) in blocks_l.iter().enumerate() { if matches!(next_open[idx], Some(pos) if pos < i) { - next_open[idx] = lower[i..].find(open_l.as_str()).map(|rel| i + rel); + next_open[idx] = find_opener(lower, open_l, i); } if let Some(pos) = next_open[idx] { if best.map_or(true, |(bp, _)| pos < bp) { @@ -747,6 +866,23 @@ fn strip_blocks_with_lower( out } +/// Byte offset of the first `opener` — a tag opening like `<style` — at or +/// after `from` in `lower` that names that tag and no longer one: followed by +/// whitespace, `>` or `/`. Without that check `<style-switcher>` or +/// `<scripts>` opened a block, and with no `</style>` after it the rest of +/// the page was dropped. +fn find_opener(lower: &str, opener: &str, from: usize) -> Option<usize> { + let mut at = from; + loop { + let pos = at + lower[at..].find(opener)?; + let next = pos + opener.len(); + match lower.as_bytes().get(next) { + None | Some(b'>' | b'/' | b' ' | b'\t' | b'\n' | b'\r' | b'\x0c') => return Some(pos), + Some(_) => at = next, + } + } +} + /// Replace any occurrence of any needle in `needles` with `repl`. Single /// pass over `hay` — beats chaining `replace_ci(hay, needle, repl)` for /// each needle, which lowercase-copies `hay` once per call. @@ -805,7 +941,9 @@ fn extract_between_ci(hay: &str, lower: &str, open: &str, close: &str) -> Option debug_assert_eq!(lower.len(), hay.len()); let open_l = open.to_ascii_lowercase(); let close_l = close.to_ascii_lowercase(); - let a = lower.find(&open_l)?; + // A tag-name match, so a `<titlebar>` ahead of the real `<title>` isn't + // taken for it. + let a = find_opener(lower, &open_l, 0)?; let rest = &lower[a..]; let b_rel = rest.find(&close_l)?; let b = a + b_rel; @@ -993,6 +1131,15 @@ mod tests { assert!(text.contains("Hello & world"), "got: {text:?}"); } + #[test] + fn title_skips_longer_tag_names() { + // `<titlebar>` opened the title match, and its `</titlebar>` closed + // it, so the page was titled "Menu". + let html = "<head><titlebar>Menu</titlebar><title>Realx"; + let (title, _) = html_to_text(html); + assert_eq!(title.as_deref(), Some("Real")); + } + #[test] fn drops_scripts() { let html = "A"; @@ -1088,6 +1235,61 @@ mod tests { assert!(is_public_ip(&"64:ff9b::808:808".parse().unwrap())); } + /// Special-purpose IPv4 ranges the screen refuses beyond the RFC 1918 + /// ones above. + #[test] + fn rejects_reserved_ipv4_ranges() { + for ip in [ + "0.1.2.3", // 0.0.0.0/8, "this network" + "0.0.0.0", // unspecified + "192.0.0.8", // 192.0.0.0/24, IETF protocol assignments + "198.19.255.1", // 198.18.0.0/15, benchmarking + "240.0.0.1", // 240.0.0.0/4, reserved + "255.255.255.255", // broadcast + "224.0.0.1", // multicast + "239.255.255.250", // multicast (SSDP) + ] { + assert!(!is_public_ip(&ip.parse().unwrap()), "{ip}"); + } + assert!(is_public_ip(&"198.20.0.1".parse().unwrap())); + assert!(is_public_ip(&"192.0.1.1".parse().unwrap())); + } + + /// IPv6 forms that hand the packet to an embedded IPv4 address, plus + /// site-local, multicast and unspecified. + #[test] + fn rejects_tunnelled_translated_and_special_ipv6() { + for ip in [ + "fec0::1", // site-local + "2002:7f00:1::1", // 6to4 around 127.0.0.1 + "2002:808:808::1", // 6to4, even around a public v4 + "2001:0:4136:e378::1", // Teredo + "64:ff9b:1::a9fe:a9fe", // local-use NAT64 + "::ffff:0:7f00:1", // IPv4-translated 127.0.0.1 + "::ffff:0:808:808", // IPv4-translated, public v4 + "ff02::1", // multicast + "::", // unspecified + ] { + assert!(!is_public_ip(&ip.parse().unwrap()), "{ip}"); + } + // Neighbours of those ranges stay public. + for ip in ["2001:4860:4860::8888", "2003::1", "64:ff9b:2::1"] { + assert!(is_public_ip(&ip.parse().unwrap()), "{ip}"); + } + } + + #[test] + fn rejects_documentation_and_discard_ipv6() { + for ip in ["2001:db8::1", "2001:db8:ffff::8888", "100::1", "100::ffff:1"] { + assert!(!is_public_ip(&ip.parse().unwrap()), "{ip}"); + } + // Neighbours stay public: 2001:db9::/32 and 100:0:0:1::/64 are + // outside both ranges. + for ip in ["2001:db9::1", "100:0:0:1::1"] { + assert!(is_public_ip(&ip.parse().unwrap()), "{ip}"); + } + } + #[test] fn rejects_ipv4_compatible_private() { // ::a.b.c.d (deprecated IPv4-compatible). ::1 is loopback and handled @@ -1114,6 +1316,32 @@ mod tests { assert!(!is_link_local(&"8.8.8.8".parse().unwrap())); } + /// 169.254.169.254 (`a9fe:a9fe`) carried by the tunnel and translator + /// forms is flagged; the same forms around any other IPv4 are not. + #[test] + fn is_link_local_looks_inside_tunnels_and_translators() { + for ip in [ + "::ffff:0:a9fe:a9fe", // IPv4-translated + "2002:a9fe:a9fe::1", // 6to4 + "2001:0:a9fe:a9fe::1", // Teredo server + "2001:0:4136:e378:8000:63bf:5601:5601", // Teredo client, inverted + "64:ff9b:1::a9fe:a9fe", // local-use NAT64, /96 + "64:ff9b:1:a9fe:a9:fe00::", // local-use NAT64, /48 + "64:ff9b:1:a9:fe:a9fe::", // local-use NAT64, /56 + "64:ff9b:1:0:a9:fea9:fe00:0", // local-use NAT64, /64 + ] { + assert!(is_link_local(&ip.parse().unwrap()), "{ip}"); + } + for ip in [ + "::ffff:0:c0a8:110", // IPv4-translated LAN + "2002:808:808::1", // 6to4 around 8.8.8.8 + "2001:0:4136:e378:8000:63bf:3fff:fdd2", // RFC 4380's example + "64:ff9b:1::c0a8:110", // LAN host via NAT64 + ] { + assert!(!is_link_local(&ip.parse().unwrap()), "{ip}"); + } + } + #[tokio::test] async fn resolve_lan_addrs_allows_lan_blocks_link_local() { // The homelab case from issue #33: a private LAN IP must resolve, not @@ -1176,6 +1404,64 @@ mod tests { ); } + #[test] + fn bodies_decode_in_the_charset_they_declare() { + // Regression: every body was read as UTF-8, so a Polish page served + // as ISO-8859-2 or windows-1250 was stored garbled. + let iso = encoding_rs::ISO_8859_2.encode("Żółw błotny").0; + assert_eq!(decode_body(&iso, "text/html; charset=\"ISO-8859-2\""), "Żółw błotny"); + let page = [ + b"" + .as_slice(), + &encoding_rs::WINDOWS_1250.encode("Zażółć gęślą jaźń").0, + b"", + ] + .concat(); + assert!(decode_body(&page, "text/html").contains("Zażółć gęślą jaźń")); + // Nothing declared: UTF-8, as before. + assert_eq!(decode_body("zwykły tekst".as_bytes(), "text/plain"), "zwykły tekst"); + assert_eq!(charset_param("text/html;charset=utf-8").as_deref(), Some("utf-8")); + assert_eq!(meta_charset(b"

no meta here, charset=nothing

"), None); + } + + /// A `charset=` counts only inside a `` tag, not wherever it first + /// turns up once any `

charset=koi8-r

"), + None + ); + assert_eq!( + meta_charset(b"").as_deref(), + Some("windows-1250") + ); + } + + /// The HTML spec reads a `` naming UTF-16 as UTF-8, and a label + /// for the "replacement" encoding decoded the page to a single U+FFFD. + #[test] + fn meta_utf16_and_replacement_labels_read_as_utf8() { + for label in ["utf-16", "unicode", "utf-16be", "iso-2022-kr", "hz-gb-2312"] { + let page = format!("Zażółć"); + let text = decode_body(page.as_bytes(), "text/html"); + assert!(text.contains("Zażółć"), "{label}: {text:?}"); + } + } + + /// `` isn't a `` after it, + /// taking it for one dropped the rest of the page. + #[test] + fn tags_that_merely_start_like_style_or_script_are_kept() { + let html = "Themes\ +

Rest of the page

\ + alert(1)End"; + let (_, text) = html_to_text(html); + assert!(text.contains("Rest of the page"), "{text:?}"); + assert!(text.contains("Theme") && text.contains("End"), "{text:?}"); + assert!(!text.contains("x{}") && !text.contains("alert"), "{text:?}"); + } + #[test] fn truncate_breaks_on_multibyte_boundary() { // Regression: the whitespace-backoff window used raw byte arithmetic diff --git a/src-tauri/src/tools/fs.rs b/src-tauri/src/tools/fs.rs index ebfcde3..036f1ff 100644 --- a/src-tauri/src/tools/fs.rs +++ b/src-tauri/src/tools/fs.rs @@ -129,9 +129,6 @@ fn cut_by_depth(entry: &walkdir::DirEntry) -> bool { /// entry's size *before* the file is read, so a multi-gigabyte log in the /// tree costs a stat, not an allocation. const MAX_SEARCH_FILE_BYTES: u64 = 512 * 1024; -/// How much of an existing file `write_file` samples to learn its line -/// endings and text encoding before overwriting it. -const FORMAT_SNIFF_BYTES: u64 = 64 * 1024; /// Project instructions file, read from the workspace root on every turn /// and appended to the system prompt — the same idea as a `CLAUDE.md` or @@ -262,6 +259,55 @@ fn unusable_name(part: &std::ffi::OsStr) -> Option<&'static str> { None } +/// Whether `name` has the shape of a Windows 8.3 short name: a `~` then a +/// digit, as in `PROGRA~1` or `IMPORT~1.MD`. +fn looks_like_short_name(name: &str) -> bool { + name.as_bytes() + .windows(2) + .any(|w| w[0] == b'~' && w[1].is_ascii_digit()) +} + +/// Whether `requested` is a short name the disk knows by a different long +/// name, `on_disk`. A difference in case alone is the same name on the +/// case-insensitive filesystems that have short names. +fn is_short_name_alias(requested: &str, on_disk: &str) -> bool { + looks_like_short_name(requested) && requested.to_lowercase() != on_disk.to_lowercase() +} + +/// Refuse a path that reaches an entry through its 8.3 short name. Windows +/// opens `IMPORT~1.MD` as `important-notes.md`, so the approval card, which +/// shows the path as the model wrote it, would name a different file from +/// the one changed. Run once a resolver's own prefix checks have passed. +/// Only components shaped like a short name are looked up, so an ordinary +/// path costs nothing extra; a link is left alone, since its canonical name +/// is its target's. +fn refuse_short_names(root: &Path, vetted: &Path, rel: &str) -> Result<(), String> { + let mut walked = root.to_path_buf(); + for part in vetted.iter() { + walked.push(part); + let requested = part.to_string_lossy(); + if !looks_like_short_name(&requested) + || fs::symlink_metadata(&walked).map_or(true, |m| m.file_type().is_symlink()) + { + continue; + } + let on_disk = walked.canonicalize().ok(); + if let Some(name) = on_disk.as_deref().and_then(Path::file_name) { + if is_short_name_alias(&requested, &name.to_string_lossy()) { + // Deliberately not naming the long name: only the resolved + // path was prefix-checked, not every step of this walk. + return Err(format!( + "`{}` reaches `{requested}` by its Windows short (8.3) name, which \ + stands for a longer one. Use the full name (list_directory shows \ + it), so the approval names the file that will actually change.", + display_rel(rel) + )); + } + } + } + Ok(()) +} + /// Confirm `candidate` — already canonical — sits inside `root`. fn ensure_inside(root: &Path, candidate: &Path, rel: &str) -> Result<(), String> { if candidate.starts_with(root) { @@ -283,6 +329,7 @@ fn resolve_read(root: &Path, rel: &str) -> Result { .canonicalize() .map_err(|e| format!("`{}` is not readable: {e}", display_rel(rel)))?; ensure_inside(root, &canon, rel)?; + refuse_short_names(root, &vetted, rel)?; Ok(canon) } @@ -342,6 +389,7 @@ fn resolve_target(root: &Path, rel: &str) -> Result { } remaining.push(part); } + refuse_short_names(root, &vetted, rel)?; // `base` is canonical and inside the root; anything left over is a // plain name that doesn't exist yet. If everything existed, `base` is @@ -370,6 +418,7 @@ fn resolve_entry(root: &Path, rel: &str) -> Result { let unreadable = |e: std::io::Error| format!("`{}` is not readable: {e}", display_rel(rel)); let dir = root.join(parent).canonicalize().map_err(unreadable)?; ensure_inside(root, &dir, rel)?; + refuse_short_names(root, &vetted, rel)?; let entry = dir.join(name); let meta = fs::symlink_metadata(&entry).map_err(unreadable)?; if meta.file_type().is_symlink() { @@ -492,19 +541,23 @@ fn match_line_endings(existing_ending: &str, content: &str) -> String { } } -/// Text encoding and line ending of the file at `path`, from its first -/// 64 KiB. Anything that can't be read, or reads as binary, counts as plain -/// UTF-8 with LF: the new content then goes to disk exactly as given, and -/// the write that follows reports any real error. +/// Text encoding and line ending of the file at `path`, judged from as much +/// of it as `read_file` and `edit_file` decode — all of it, up to +/// [`MAX_READ_FILE_BYTES`]. Sampling only the first 64 KiB took a +/// Windows-1250 file whose first accented letter came later for UTF-8 — +/// which read_file, decoding all of it, didn't — and the overwrite +/// converted it. Anything that can't be read, or reads as +/// binary, counts as plain UTF-8 with LF: the new content then goes to disk +/// exactly as given, and the write that follows reports any real error. fn sniff_format(path: &Path) -> (TextEncoding, &'static str) { const PLAIN: (TextEncoding, &str) = (TextEncoding::Utf8 { bom: false }, "\n"); let mut head = Vec::new(); let read = fs::File::open(path) - .and_then(|f| f.take(FORMAT_SNIFF_BYTES).read_to_end(&mut head)); + .and_then(|f| f.take(MAX_READ_FILE_BYTES).read_to_end(&mut head)); if read.is_err() { return PLAIN; } - let complete = (head.len() as u64) < FORMAT_SNIFF_BYTES; + let complete = (head.len() as u64) < MAX_READ_FILE_BYTES; match encoding::decode(&head, complete) { Some(d) => (d.encoding, line_ending_of(&d.text)), None => PLAIN, @@ -550,6 +603,10 @@ fn kept_encoding_note(enc: TextEncoding) -> String { /// /// An existing file's permissions carry over (an executable script stays /// executable), and a read-only one is refused, as an in-place write was. +/// On Unix the new file is created with the old one's mode, so a `0600` +/// `.env` is never readable by others while it's being written. On Windows +/// it gets the old one's ACL before anything is written to it — see +/// [`copy_dacl`]. fn replace_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> { use std::io::Write as _; use std::sync::atomic::{AtomicU64, Ordering}; @@ -572,7 +629,20 @@ fn replace_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> { NEXT.fetch_add(1, Ordering::Relaxed) )); let written = (|| { - let mut f = fs::OpenOptions::new().write(true).create_new(true).open(&tmp)?; + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if let Some(m) = &existing { + use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; + // The umask can only take bits away; `set_permissions` below + // restores the exact mode. + options.mode(m.permissions().mode()); + } + let mut f = options.open(&tmp)?; + #[cfg(windows)] + if existing.is_some() { + copy_dacl(path, &tmp)?; + } f.write_all(bytes)?; f.sync_all()?; drop(f); @@ -587,30 +657,176 @@ fn replace_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> { written } -/// Whether a call's `path` — or either end of a move — lies inside a `.git` -/// directory, as written or once links are resolved. A standing "Allow … -/// for this chat" never covers those: a change there shows in no `git -/// status` or diff, and a hook or config setting there runs commands the -/// next time the user runs git — a way out of the folder that a grant given -/// for ordinary edits was never meant to open. -pub fn touches_git_dir(root: &Path, args: &Value) -> bool { - let has_git = |p: &Path| { - p.components().any(|c| { - matches!(c, Component::Normal(n) if n.to_string_lossy().eq_ignore_ascii_case(".git")) - }) +/// Give `tmp` the DACL `original` has — its explicit entries, and whether it +/// inherits from the folder at all — where a new file takes only what the +/// folder hands down, so a file locked down to one user would come out of +/// [`replace_file`] readable by everyone the folder lets in. The inherited +/// entries `SetNamedSecurityInfoW` recomputes from the folder, which both +/// files share. +/// +/// Nothing is set when the two already match — a file with only inherited +/// entries, the common case — so an ordinary edit never depends on the +/// filesystem accepting an ACL. Any other failure fails the write rather +/// than quietly widening who can read the file. +#[cfg(windows)] +fn copy_dacl(original: &Path, tmp: &Path) -> std::io::Result<()> { + use windows::core::HSTRING; + use windows::Win32::Foundation::{LocalFree, ERROR_SUCCESS, HLOCAL}; + use windows::Win32::Security::Authorization::{ + GetNamedSecurityInfoW, SetNamedSecurityInfoW, SE_FILE_OBJECT, + }; + use windows::Win32::Security::{ + GetSecurityDescriptorControl, ACL, DACL_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, SE_DACL_PROTECTED, + UNPROTECTED_DACL_SECURITY_INFORMATION, + }; + + /// A file's security descriptor, with its DACL (null when it has none) + /// and whether that is protected from inheritance. + struct Dacl { + sd: PSECURITY_DESCRIPTOR, + acl: *mut ACL, + protected: bool, + } + impl Drop for Dacl { + fn drop(&mut self) { + // SAFETY: `sd` is null or the LocalAlloc'd descriptor that + // GetNamedSecurityInfoW returned, which the caller frees. + unsafe { LocalFree(Some(HLOCAL(self.sd.0))) }; + } + } + impl Dacl { + fn bytes(&self) -> &[u8] { + if self.acl.is_null() { + return &[]; + } + // SAFETY: a non-null DACL points into `sd`, alive as long as + // `self`, and `AclSize` is its length in bytes, entries included. + unsafe { std::slice::from_raw_parts(self.acl.cast::(), (*self.acl).AclSize.into()) } + } + } + let read = |p: &Path| -> std::io::Result { + let mut d = Dacl { + sd: PSECURITY_DESCRIPTOR::default(), + acl: std::ptr::null_mut(), + protected: false, + }; + // SAFETY: the name outlives the call, and the out-pointers are live + // locals; `d`'s drop frees what it allocates. + let e = unsafe { + GetNamedSecurityInfoW( + &HSTRING::from(p), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + None, + None, + Some(&mut d.acl), + None, + &mut d.sd, + ) + }; + if e != ERROR_SUCCESS { + return Err(std::io::Error::from_raw_os_error(e.0 as i32)); + } + let (mut control, mut revision) = (0u16, 0u32); + // SAFETY: `sd` is the valid descriptor just returned. + unsafe { GetSecurityDescriptorControl(d.sd, &mut control, &mut revision) } + // The HRESULT wraps a Win32 error code in its low 16 bits. + .map_err(|e| std::io::Error::from_raw_os_error(e.code().0 & 0xFFFF))?; + d.protected = control & SE_DACL_PROTECTED.0 != 0; + Ok(d) + }; + + let want = read(original)?; + let has = read(tmp)?; + if want.acl.is_null() || (want.protected == has.protected && want.bytes() == has.bytes()) { + return Ok(()); + } + let inheritance = if want.protected { + PROTECTED_DACL_SECURITY_INFORMATION + } else { + UNPROTECTED_DACL_SECURITY_INFORMATION }; + // SAFETY: the name outlives the call and `want.acl` points into the + // descriptor `want` keeps alive. + let e = unsafe { + SetNamedSecurityInfoW( + &HSTRING::from(tmp), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | inheritance, + None, + None, + Some(want.acl.cast_const()), + None, + ) + }; + if e != ERROR_SUCCESS { + return Err(std::io::Error::from_raw_os_error(e.0 as i32)); + } + Ok(()) +} + +/// Whether a call's `path` — or either end of a move — lies where git runs +/// what it finds the next time the user runs git, as written or once links +/// are resolved. A standing "Allow … for this chat" never covers those: a +/// hook or config setting there runs commands — a way out of the folder +/// that a grant given for ordinary edits was never meant to open. +/// +/// That's a `.git` directory (whose changes also show in no `git status` +/// or diff), the hook folders `core.hooksPath` is pointed at by husky +/// (`.husky`) and by convention (`.githooks`), a `.pre-commit-config.yaml` +/// (a `repo: local` hook runs any command), and any folder laid out as a +/// bare repository — see [`in_git_dir_layout`]. +pub fn touches_git_dir(root: &Path, args: &Value) -> bool { ["path", "from", "to"] .iter() .filter_map(|key| args.get(*key)?.as_str()) .any(|rel| { - has_git(Path::new(rel.trim())) - || resolve_target(root, rel) - .ok() - .and_then(|p| p.strip_prefix(root).ok().map(has_git)) - .unwrap_or(false) + runs_with_git(Path::new(rel.trim())) + || resolve_target(root, rel).ok().is_some_and(|p| { + p.strip_prefix(root).is_ok_and(runs_with_git) || in_git_dir_layout(root, &p) + }) }) } +/// Names git, or the hook manager it calls, takes commands from — see +/// [`touches_git_dir`]. +const GIT_RUN_NAMES: &[&str] = &[".git", ".husky", ".githooks", ".pre-commit-config.yaml"]; + +/// Whether any component of `p` is one of [`GIT_RUN_NAMES`]. +fn runs_with_git(p: &Path) -> bool { + p.components().any(|c| { + matches!(c, Component::Normal(n) if { + let n = n.to_string_lossy(); + GIT_RUN_NAMES.iter().any(|name| n.eq_ignore_ascii_case(name)) + }) + }) +} + +/// Whether `target`, once written, lies in a folder — the root itself or +/// any on the way down to it — laid out as a git directory. A `HEAD` beside +/// `objects/` and `refs/` is all git needs to open one as a bare +/// repository, and opening it runs whatever its `config` names as +/// `core.fsmonitor`; editors that scan a project for nested repositories +/// open them unasked. `target` counts as present, so the call that would +/// complete such a layout asks too, not only those into a finished one. +fn in_git_dir_layout(root: &Path, target: &Path) -> bool { + let Ok(rel) = target.strip_prefix(root) else { + return false; + }; + let mut dir = root.to_path_buf(); + for part in rel.iter() { + let present = |marker: &str| { + part.to_string_lossy().eq_ignore_ascii_case(marker) || dir.join(marker).exists() + }; + if present("HEAD") && present("objects") && present("refs") { + return true; + } + dir.push(part); + } + false +} + /// For the approval card: the size of the file a `write_file` call would /// replace, `Some(None)` when it would create a new one, and `None` when /// its path doesn't resolve — the call itself is refused then, with the @@ -1150,6 +1366,16 @@ fn find_files(root: &Path, args: &Value, max_visited: usize) -> McpCallResult { ok(text) } +/// The contents of `path`, or `None` when it holds more than `cap` bytes. +/// The size checks before a read are a cheap early answer, not the bound: +/// a log still being written, or a FUSE file that reports no size, can be +/// far past it by the time it's read — so the read stops at `cap + 1`. +fn read_capped(path: &Path, cap: u64) -> std::io::Result>> { + let mut bytes = Vec::new(); + fs::File::open(path)?.take(cap + 1).read_to_end(&mut bytes)?; + Ok((bytes.len() as u64 <= cap).then_some(bytes)) +} + pub fn dispatch_read_file(root: &Path, args: &Value) -> McpCallResult { let Some(rel) = args.get("path").and_then(|v| v.as_str()) else { return err("missing required `path` argument (string)"); @@ -1170,18 +1396,21 @@ pub fn dispatch_read_file(root: &Path, args: &Value) -> McpCallResult { // Size first, so a huge file is refused with a stat rather than after // being pulled into memory. - let len = fs::metadata(&path).map(|m| m.len()).unwrap_or(0); - if len > MAX_READ_FILE_BYTES { - return err(format!( - "`{}` is {}; read_file handles files up to {}. Use search_files to \ + let too_big = |size: String| { + err(format!( + "`{}` is {size}; read_file handles files up to {}. Use search_files to \ find the lines you need.", display_rel(rel), - human_size(len), human_size(MAX_READ_FILE_BYTES) - )); + )) + }; + let len = fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + if len > MAX_READ_FILE_BYTES { + return too_big(human_size(len)); } - let bytes = match fs::read(&path) { - Ok(b) => b, + let bytes = match read_capped(&path, MAX_READ_FILE_BYTES) { + Ok(Some(b)) => b, + Ok(None) => return too_big(format!("over {}", human_size(MAX_READ_FILE_BYTES))), Err(e) => return err(format!("couldn't read `{}`: {e}", display_rel(rel))), }; let Some(decoded) = encoding::decode(&bytes, true) else { @@ -1361,7 +1590,9 @@ pub fn dispatch_search_files(root: &Path, args: &Value) -> McpCallResult { if size > MAX_SEARCH_FILE_BYTES { continue; } - let Ok(bytes) = fs::read(path) else { continue }; + let Ok(Some(bytes)) = read_capped(path, MAX_SEARCH_FILE_BYTES) else { + continue; + }; files_read += 1; // Each file in its own encoding, so a Windows-1250 or UTF-16 file // is searched rather than skipped; only binaries are. @@ -1507,17 +1738,20 @@ pub fn dispatch_edit_file(root: &Path, args: &Value) -> McpCallResult { // Size first, as in `read_file`: the whole file is about to be held in // memory, and anything past this is a file the model couldn't have read // the text to edit from anyway. - let len = fs::metadata(&path).map(|m| m.len()).unwrap_or(0); - if len > MAX_READ_FILE_BYTES { - return err(format!( - "`{}` is {}; edit_file handles files up to {}, the same as read_file.", + let too_big = |size: String| { + err(format!( + "`{}` is {size}; edit_file handles files up to {}, the same as read_file.", display_rel(rel), - human_size(len), human_size(MAX_READ_FILE_BYTES) - )); + )) + }; + let len = fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + if len > MAX_READ_FILE_BYTES { + return too_big(human_size(len)); } - let bytes = match fs::read(&path) { - Ok(b) => b, + let bytes = match read_capped(&path, MAX_READ_FILE_BYTES) { + Ok(Some(b)) => b, + Ok(None) => return too_big(format!("over {}", human_size(MAX_READ_FILE_BYTES))), Err(e) => return err(format!("couldn't read `{}`: {e}", display_rel(rel))), }; let Some(decoded) = encoding::decode(&bytes, true) else { @@ -1663,9 +1897,13 @@ pub fn dispatch_move_file(root: &Path, args: &Value) -> McpCallResult { // `to` resolved to `from` itself. On a case-insensitive filesystem // (Windows, macOS) that is how a rename changing only case arrives — // `readme.md` → `README.md` — and it is allowed: the one entry is - // renamed and nothing is overwritten. + // renamed and nothing is overwritten. On a case-sensitive one the + // new name can be an entry of its own that leads to `from` — a link + // `README.md` → `readme.md` — and the replacing rename below would + // overwrite it, so the new name must open the very same entry. match case_renamed(&src, to) { - Some(renamed) => renamed, + Some(renamed) if same_entry(&src, &renamed) => renamed, + Some(_) => return err(already_exists(to)), None => { return err(format!( "`{}` already exists — it is `{}` itself, or a link to it.", @@ -1789,6 +2027,41 @@ fn case_renamed(src: &Path, to: &str) -> Option { (wanted != current && same_letters).then(|| src.with_file_name(wanted)) } +/// Whether `a` and `b` open one directory entry — the same file id, links +/// not followed. Two spellings of a name do on a case-insensitive +/// filesystem; on a case-sensitive one `Notes.md` is an entry of its own. +/// Anything that can't be opened counts as different. +fn same_entry(a: &Path, b: &Path) -> bool { + #[cfg(unix)] + let id = |p: &Path| { + use std::os::unix::fs::MetadataExt as _; + fs::symlink_metadata(p).ok().map(|m| (m.dev(), m.ino())) + }; + #[cfg(windows)] + let id = |p: &Path| { + use std::os::windows::fs::OpenOptionsExt as _; + use std::os::windows::io::AsRawHandle as _; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, + FILE_FLAG_OPEN_REPARSE_POINT, + }; + // No access asked for, just the id; the flags open a directory, and + // a link as itself. + let file = fs::OpenOptions::new() + .access_mode(0) + .custom_flags((FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT).0) + .open(p) + .ok()?; + let mut info = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: `file` keeps the handle open for the call, and `info` is + // a struct of plain integers the call fills in. + unsafe { GetFileInformationByHandle(HANDLE(file.as_raw_handle()), &mut info) }.ok()?; + Some((info.dwVolumeSerialNumber, info.nFileIndexHigh, info.nFileIndexLow)) + }; + matches!((id(a), id(b)), (Some(x), Some(y)) if x == y) +} + pub fn dispatch_delete_file(root: &Path, args: &Value) -> McpCallResult { let Some(rel) = args.get("path").and_then(|v| v.as_str()) else { return err("missing required `path` argument (string)"); @@ -2128,6 +2401,56 @@ mod tests { let w = dispatch_write_file(&root, &json!({ "path": "run.sh", "content": "#!/bin/sh\necho new\n" })); assert!(!w.is_error, "{}", w.content_text); assert_eq!(fs::metadata(&script).unwrap().permissions().mode() & 0o777, 0o755); + + // A private file stays private (it is also created that way, so it + // isn't readable by others while the write is in flight). + let env = root.join(".env"); + fs::write(&env, "TOKEN=old\n").unwrap(); + fs::set_permissions(&env, fs::Permissions::from_mode(0o600)).unwrap(); + let w = dispatch_write_file(&root, &json!({ "path": ".env", "content": "TOKEN=new\n" })); + assert!(!w.is_error, "{}", w.content_text); + assert_eq!(fs::metadata(&env).unwrap().permissions().mode() & 0o777, 0o600); + } + + /// The Windows side of the test above: a file's own ACL — inheritance cut + /// down to one grant, or an extra grant beside what the folder hands + /// down — comes through an edit and a write as it was, where the new file + /// used to take only the folder's. + #[cfg(windows)] + #[test] + fn writes_keep_the_file_acl() { + use std::process::Command; + let (_d, root) = workspace(); + let owner_only = format!("{}:(F)", std::env::var("USERNAME").expect("USERNAME")); + // BUILTIN\Users by SID, so the name doesn't depend on the locale. + let cases: [(&str, &[&str]); 2] = [ + ("private.env", &["/inheritance:r", "/grant:r", &owner_only]), + ("shared.txt", &["/grant", "*S-1-5-32-545:(R)"]), + ]; + for (name, grant) in cases { + let path = root.join(name); + fs::write(&path, "TOKEN=old\n").unwrap(); + let shown = dunce::simplified(&path).to_owned(); + let Ok(granted) = Command::new("icacls").arg(&shown).args(grant).output() else { + eprintln!("icacls unavailable; skipping"); + return; + }; + assert!(granted.status.success(), "icacls {grant:?}: {granted:?}"); + let acl = || { + let out = Command::new("icacls").arg(&shown).output().unwrap(); + String::from_utf8_lossy(&out.stdout).into_owned() + }; + let before = acl(); + + let r = if name == "private.env" { + dispatch_edit_file(&root, &json!({ "path": name, "old_text": "old", "new_text": "new" })) + } else { + dispatch_write_file(&root, &json!({ "path": name, "content": "TOKEN=new\n" })) + }; + assert!(!r.is_error, "{}", r.content_text); + assert_eq!(fs::read_to_string(&path).unwrap(), "TOKEN=new\n"); + assert_eq!(acl(), before, "{name}"); + } } /// No standing grant covers `.git`: hooks and config there run commands. @@ -2141,6 +2464,114 @@ mod tests { assert!(!touches_git_dir(&root, &json!({ "path": ".gitignore" }))); } + /// Hook folders and the pre-commit config run commands on the user's + /// next commit just as `.git/hooks` does, so no grant covers them either. + #[test] + fn hook_folders_and_the_pre_commit_config_are_recognised() { + for p in [ + ".husky/pre-commit", + ".githooks/post-checkout", + ".pre-commit-config.yaml", + "sub/.Pre-Commit-Config.yaml", + ".HUSKY/_/h", + ] { + assert!(runs_with_git(Path::new(p)), "{p}"); + } + for p in [".husky-notes.md", "pre-commit-config.yaml", ".github/workflows/ci.yml", "docs/githooks.md"] { + assert!(!runs_with_git(Path::new(p)), "{p}"); + } + let (_d, root) = workspace(); + assert!(touches_git_dir(&root, &json!({ "from": "a.yaml", "to": ".pre-commit-config.yaml" }))); + } + + /// A folder with `HEAD`, `objects/` and `refs/` is a bare repository to + /// git, whose `config` can name a `core.fsmonitor` command. A write into + /// one, or the write that completes one, always asks. + #[test] + fn writes_into_a_bare_repository_layout_are_recognised() { + let (_d, root) = workspace(); + fs::create_dir_all(root.join("x/objects")).unwrap(); + fs::write(root.join("x/HEAD"), "ref: refs/heads/main\n").unwrap(); + // Not a repository yet: `refs/` is missing. + assert!(!touches_git_dir(&root, &json!({ "path": "x/config" }))); + // …but the write that adds it would make one. + assert!(touches_git_dir(&root, &json!({ "path": "x/refs/heads/main" }))); + fs::create_dir_all(root.join("x/refs")).unwrap(); + assert!(touches_git_dir(&root, &json!({ "path": "x/config" }))); + assert!(touches_git_dir(&root, &json!({ "path": "x/hooks/deep/file" }))); + assert!(touches_git_dir(&root, &json!({ "from": "README.md", "to": "x/description" }))); + assert!(!touches_git_dir(&root, &json!({ "path": "src/main.rs" }))); + assert!(!touches_git_dir(&root, &json!({ "path": "xy/config" }))); + + // A workspace that is itself a git directory: everything in it asks. + let (_d2, bare) = workspace(); + fs::create_dir_all(bare.join("objects")).unwrap(); + fs::create_dir_all(bare.join("refs")).unwrap(); + fs::write(bare.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + assert!(touches_git_dir(&bare, &json!({ "path": "config" }))); + } + + /// The approval card shows the path as the model wrote it, so a Windows + /// short name that opens a differently named file must be refused. The + /// comparison is tested on its own: whether a volume generates short + /// names is a per-machine setting. + #[test] + fn short_name_aliases_are_told_apart_from_real_names() { + assert!(looks_like_short_name("IMPORT~1.MD")); + assert!(looks_like_short_name("PROGRA~12")); + assert!(!looks_like_short_name("notes~.md")); + assert!(!looks_like_short_name("file.txt")); + // A short name standing for a longer one. + assert!(is_short_name_alias("IMPORT~1.MD", "important-notes.md")); + assert!(is_short_name_alias("GIT~1", ".git")); + // The file really has that name, in any case. + assert!(!is_short_name_alias("import~1.md", "IMPORT~1.MD")); + assert!(!is_short_name_alias("backup~1.txt", "backup~1.txt")); + // Names not shaped like a short name — a case change, a link to a + // differently named target — are the sandbox's business, not this. + assert!(!is_short_name_alias("notes.md", "Notes.md")); + assert!(!is_short_name_alias("docs", "documentation")); + + // A real file literally named like a short name still works. + let (_d, root) = workspace(); + fs::write(root.join("backup~1.txt"), "x").unwrap(); + let w = dispatch_write_file(&root, &json!({ "path": "backup~1.txt", "content": "y" })); + assert!(!w.is_error, "{}", w.content_text); + } + + /// Where the volume does generate short names, every path through one is + /// refused and the long-named file is left alone. + #[cfg(windows)] + #[test] + fn a_short_name_alias_is_refused_on_disk() { + use windows::core::HSTRING; + use windows::Win32::Storage::FileSystem::GetShortPathNameW; + let (_d, root) = workspace(); + let long = root.join("important-notes-for-taxes.md"); + fs::write(&long, "original\n").unwrap(); + let mut buf = [0u16; 1024]; + // SAFETY: the path outlives the call, and the buffer length is passed. + let n = unsafe { GetShortPathNameW(&HSTRING::from(long.as_path()), Some(&mut buf)) } as usize; + let short = PathBuf::from(String::from_utf16_lossy(&buf[..n.min(buf.len())])); + let short_name = short.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if !looks_like_short_name(&short_name) { + eprintln!("8.3 names are off on this volume; skipping"); + return; + } + let w = dispatch_write_file(&root, &json!({ "path": short_name, "content": "changed\n" })); + assert!(w.is_error && w.content_text.contains("short (8.3) name"), "{}", w.content_text); + let e = dispatch_edit_file( + &root, + &json!({ "path": short_name, "old_text": "original", "new_text": "changed" }), + ); + assert!(e.is_error, "{}", e.content_text); + let d = dispatch_delete_file(&root, &json!({ "path": short_name })); + assert!(d.is_error, "{}", d.content_text); + let m = dispatch_move_file(&root, &json!({ "from": short_name, "to": "moved.md" })); + assert!(m.is_error, "{}", m.content_text); + assert_eq!(fs::read_to_string(&long).unwrap(), "original\n"); + } + #[test] fn write_target_size_tells_a_new_file_from_an_overwrite() { let (_d, root) = workspace(); @@ -2896,6 +3327,47 @@ mod tests { assert!(same.content_text.contains("already exists"), "{}", same.content_text); } + /// On a case-sensitive filesystem `Notes.md` can be a link to + /// `notes.md`, so `to` resolves to `from` like a case-only rename — and + /// the replacing rename that one takes used to overwrite the link. + #[test] + fn a_case_only_rename_never_replaces_a_separate_entry() { + let (_d, root) = workspace(); + fs::write(root.join("notes.md"), "x\n").unwrap(); + // A case-insensitive filesystem has no room for the link: nothing to test. + if !try_symlink(&root.join("notes.md"), &root.join("Notes.md"), false) { + return; + } + let r = dispatch_move_file(&root, &json!({ "from": "notes.md", "to": "Notes.md" })); + assert!(r.is_error, "{}", r.content_text); + assert!(r.content_text.contains("already exists"), "{}", r.content_text); + assert!(fs::symlink_metadata(root.join("Notes.md")).unwrap().file_type().is_symlink()); + assert_eq!(fs::read_to_string(root.join("notes.md")).unwrap(), "x\n"); + } + + #[test] + fn same_entry_tells_one_entry_from_two() { + let (_d, root) = workspace(); + let readme = root.join("README.md"); + assert!(same_entry(&readme, &readme)); + assert!(!same_entry(&readme, &root.join("src/main.rs"))); + assert!(!same_entry(&readme, &root.join("absent.md"))); + if try_symlink(&readme, &root.join("alias.md"), false) { + assert!(!same_entry(&readme, &root.join("alias.md")), "a link is an entry of its own"); + } + } + + /// A file's size can be out of date by the time it's read, so the read + /// itself stops one byte past the cap. + #[test] + fn read_capped_refuses_more_than_the_cap() { + let (_d, root) = workspace(); + let path = root.join("grow.log"); + fs::write(&path, "12345").unwrap(); + assert_eq!(read_capped(&path, 5).unwrap().as_deref(), Some(b"12345".as_slice())); + assert_eq!(read_capped(&path, 4).unwrap(), None); + } + /// The 1 MiB write cap used to apply to the *edited file*, so nothing /// between 1 and 4 MiB — readable by `read_file` — could be edited at all. #[test] @@ -3039,6 +3511,26 @@ mod tests { assert_eq!(fs::read(root.join("pl.txt")).unwrap(), cp1250("Źdźbło\r\n")); } + /// An overwrite judged the encoding from the first 64 KiB, so a + /// Windows-1250 file whose first accented letter came later was taken + /// for UTF-8 and converted — while read_file, decoding all of it, called + /// it Windows-1250. + #[test] + fn write_judges_the_encoding_from_as_much_as_read_file_decodes() { + let (_d, root) = workspace(); + let mut original = "plain ascii line\r\n".repeat(200 * 1024 / 18).into_bytes(); + original.extend(cp1250("Zażółć gęślą jaźń\r\n")); + assert!(original.len() > 64 * 1024 * 3); + fs::write(root.join("big.txt"), &original).unwrap(); + + let r = dispatch_read_file(&root, &json!({ "path": "big.txt", "max_lines": 1 })); + assert!(r.content_text.contains("windows-1250"), "{}", r.content_text); + let w = dispatch_write_file(&root, &json!({ "path": "big.txt", "content": "łódź\n" })); + assert!(!w.is_error, "{}", w.content_text); + assert!(w.content_text.contains("Kept its windows-1250 encoding"), "{}", w.content_text); + assert_eq!(fs::read(root.join("big.txt")).unwrap(), cp1250("łódź\r\n")); + } + /// A character the file's encoding can't store is refused — never /// written as `?` — and the file is left exactly as it was. #[test] diff --git a/src-tauri/src/tools/hash.rs b/src-tauri/src/tools/hash.rs index 4e70c9c..6b635ce 100644 --- a/src-tauri/src/tools/hash.rs +++ b/src-tauri/src/tools/hash.rs @@ -4,7 +4,6 @@ //! is just made up. SHA-1 and MD5 are intentionally not exposed: they //! are broken for cryptographic use and we'd rather not encourage them. -use base64::Engine; use serde_json::{json, Value}; use sha2::{Digest, Sha224, Sha256, Sha384, Sha512}; @@ -22,7 +21,8 @@ pub fn tool_description() -> &'static str { shape. Algorithms: `sha224`, `sha256` (default), `sha384`, `sha512`. \ `input_format` controls how `input` is interpreted before hashing: \ `utf8` (default), `hex` (decode a hex string), `base64` (decode a \ - standard base64 string). Output is lowercase hex of the digest." + standard or URL-safe base64 string; padding optional). Output is \ + lowercase hex of the digest." } pub fn input_schema() -> Value { @@ -85,13 +85,13 @@ fn decode_input(input: &str, fmt: &str) -> Result, String> { "utf8" => Ok(input.as_bytes().to_vec()), "hex" => { let cleaned: String = input.chars().filter(|c| !c.is_ascii_whitespace()).collect(); - // Reject non-ASCII before the byte-index slicing below. `cleaned` - // is built by filtering `chars()`, so a retained multi-byte char - // (e.g. `中`) would otherwise make `&cleaned[i..i + 2]` slice mid- - // character and panic ("byte index N is not a char boundary"). - // Hex digits are ASCII by definition, so anything else is invalid - // input, not a crash. - if !cleaned.is_ascii() { + // Reject anything but hex digits before the byte-index slicing + // below. `cleaned` is built by filtering `chars()`, so a retained + // multi-byte char (e.g. `中`) would otherwise make + // `&cleaned[i..i + 2]` slice mid-character and panic ("byte index + // N is not a char boundary"). And `from_str_radix` takes a sign: + // `+a+b` hashed as the bytes 0a 0b. + if !cleaned.bytes().all(|b| b.is_ascii_hexdigit()) { return Err("hex input must contain only ASCII hex digits".to_string()); } if cleaned.len() % 2 != 0 { @@ -103,8 +103,10 @@ fn decode_input(input: &str, fmt: &str) -> Result, String> { .collect::, _>>() .map_err(|e| format!("invalid hex input: {e}")) } - "base64" => base64::engine::general_purpose::STANDARD - .decode(input.trim()) + // Read as leniently as the `base64` tool decodes — no padding needed, + // line breaks ignored — in either alphabet: `-` and `_` only occur in + // the URL-safe one. + "base64" => super::base64_tool::decode_lenient(input, input.contains(['-', '_'])) .map_err(|e| format!("invalid base64 input: {e}")), other => Err(format!("unknown input_format `{other}` — use utf8/hex/base64")), } @@ -180,4 +182,26 @@ mod tests { let r = dispatch(&json!({"input": "x", "algorithm": "md5"})); assert!(r.is_error); } + + /// `from_str_radix` accepts a leading sign, which isn't a hex digit. + #[test] + fn hex_input_rejects_signs() { + let r = dispatch(&json!({"input": "+a+b", "input_format": "hex"})); + assert!(r.is_error, "{}", r.content_text); + } + + /// The bytes fb ff: `+/8=` in the standard alphabet, `-_8=` in the + /// URL-safe one. Every spelling the `base64` tool decodes hashes alike. + #[test] + fn base64_input_decodes_like_the_base64_tool() { + let digest = |input: &str, format: &str| { + let r = dispatch(&json!({"input": input, "input_format": format})); + assert!(!r.is_error, "{input}: {}", r.content_text); + r.content_text + }; + let want = digest("fbff", "hex"); + for input in ["+/8=", "+/8", "-_8=", "-_8", "+/\n8="] { + assert_eq!(digest(input, "base64"), want, "{input}"); + } + } } diff --git a/src-tauri/src/tools/ip_tool.rs b/src-tauri/src/tools/ip_tool.rs index 2a5b740..993c536 100644 --- a/src-tauri/src/tools/ip_tool.rs +++ b/src-tauri/src/tools/ip_tool.rs @@ -218,6 +218,49 @@ mod tests { assert!(r.content_text.contains("host_count: 2")); } + fn subnet_info(cidr: &str) -> String { + let r = dispatch(&json!({"op": "subnet_info", "cidr": cidr})); + assert!(!r.is_error, "{cidr}: {}", r.content_text); + r.content_text + } + + #[test] + fn subnet_info_ipv4_whole_space() { + assert_eq!( + subnet_info("0.0.0.0/0"), + "network: 0.0.0.0\nbroadcast: 255.255.255.255\nprefix_len: 0\nnetmask: 0.0.0.0\n\ + first_host: 0.0.0.1\nlast_host: 255.255.255.254\nhost_count: 4294967294" + ); + } + + /// 2^128 addresses don't fit the counter: the count saturates and says so. + #[test] + fn subnet_info_ipv6_whole_space_flags_the_saturated_count() { + let out = subnet_info("::/0"); + assert!(out.contains("network: ::\n"), "{out}"); + assert!(out.contains("first_host: ::\n"), "{out}"); + assert!(out.contains("last_host: ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff\n"), "{out}"); + assert!( + out.ends_with(&format!("host_count: {} (floor — 2^128 exceeds the counter)", u128::MAX)), + "{out}" + ); + } + + #[test] + fn subnet_info_single_addresses() { + assert_eq!( + subnet_info("10.1.2.3/32"), + "network: 10.1.2.3\nbroadcast: 10.1.2.3\nprefix_len: 32\nnetmask: 255.255.255.255\n\ + first_host: 10.1.2.3\nlast_host: 10.1.2.3\nhost_count: 1" + ); + assert_eq!( + subnet_info("2001:db8::5/128"), + "network: 2001:db8::5\nprefix_len: 128\n\ + netmask: ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff\n\ + first_host: 2001:db8::5\nlast_host: 2001:db8::5\nhost_count: 1" + ); + } + #[test] fn ipv6_in_cidr() { let r = dispatch(&json!({ diff --git a/src-tauri/src/tools/json_tool.rs b/src-tauri/src/tools/json_tool.rs index 56e2c3b..0472a43 100644 --- a/src-tauri/src/tools/json_tool.rs +++ b/src-tauri/src/tools/json_tool.rs @@ -27,7 +27,10 @@ pub fn tool_description() -> &'static str { validates as a side effect), \ `extract` (returns the value at a JSON Pointer path, RFC 6901, e.g. \ `/users/0/name`; leading slash, slash-separated, integer indices for \ - arrays, `~1` escapes `/`, `~0` escapes `~`)." + arrays, `~1` escapes `/`, `~0` escapes `~`). `format` and `extract` \ + keep key order but re-write numbers as 64-bit values: an integer \ + beyond the 64-bit range, or a decimal with more than ~17 significant \ + digits, comes back rounded." } pub fn input_schema() -> Value { @@ -74,9 +77,10 @@ pub fn dispatch(args: &Value) -> McpCallResult { // `maximum: 8` but Tauri does not enforce JSON-Schema at the IPC // boundary, so a model that ignores the schema could otherwise // pass `indent: 1_000_000` and allocate a 1 MiB indent string. - let indent = args - .get("indent") - .and_then(|v| v.as_u64()) + // `lenient_i64` so a float-shaped `4.0` counts rather than + // falling back to the default unnoticed. + let indent = crate::tools::lenient_i64(args, "indent") + .and_then(|n| u64::try_from(n).ok()) .unwrap_or(2) .min(8); // serde_json::to_string_pretty hard-codes 2-space indent, so @@ -105,12 +109,25 @@ pub fn dispatch(args: &Value) -> McpCallResult { Ok(v) => v, Err(e) => return err(format!("invalid JSON at line {} column {}: {e}", e.line(), e.column())), }; - match parsed.pointer(path) { + // A pointer starts with `/`; `users/0/name` is one with the slash + // forgotten, so read it as meant rather than report it missing. + let pointer = if path.is_empty() || path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + match parsed.pointer(&pointer) { Some(v) => ok( serde_json::to_string_pretty(v) .unwrap_or_else(|e| format!("formatting failed: {e}")), ), - None => err(format!("no value found at JSON Pointer `{path}`")), + // JSONPath (`$.users[0].name`) finds nothing as a pointer, and + // "no value" would read as a missing key. + None if path.starts_with('$') || path.contains('[') => err(format!( + "`{path}` looks like JSONPath; this tool takes a JSON Pointer \ + instead — `$.users[0].name` is `/users/0/name`" + )), + None => err(format!("no value found at JSON Pointer `{pointer}`")), } } other => err(format!("unknown op `{other}`")), @@ -167,6 +184,19 @@ mod tests { assert!(r.content_text.contains("\n \"a\"")); } + /// Keys come back in the order the model wrote them; without serde_json's + /// `preserve_order` they were re-sorted alphabetically. + #[test] + fn format_and_extract_keep_key_order() { + let input = r#"{"zeta":1,"alpha":{"y":2,"b":3},"mid":[{"k2":0,"k1":0}]}"#; + let r = dispatch(&json!({"input": input, "op": "format", "indent": 0})); + assert!(!r.is_error, "{}", r.content_text); + assert_eq!(r.content_text.replace('\n', ""), input.replace(':', ": ")); + let r = dispatch(&json!({"input": input, "op": "extract", "path": "/alpha"})); + assert!(!r.is_error, "{}", r.content_text); + assert_eq!(r.content_text, "{\n \"y\": 2,\n \"b\": 3\n}"); + } + #[test] fn extract_walks_path() { let r = dispatch(&json!({ @@ -191,4 +221,32 @@ mod tests { let r = dispatch(&json!({"input": r#"{"a":1}"#, "op": "extract", "path": "/missing"})); assert!(r.is_error); } + + #[test] + fn extract_reads_a_path_missing_its_leading_slash() { + let input = r#"{"users":[{"name":"Ada"}]}"#; + let r = dispatch(&json!({"input": input, "op": "extract", "path": "users/0/name"})); + assert!(!r.is_error, "{}", r.content_text); + assert_eq!(r.content_text, "\"Ada\""); + let r = dispatch(&json!({"input": input, "op": "extract", "path": "users/1"})); + assert!(r.is_error); + assert!(r.content_text.contains("`/users/1`"), "{}", r.content_text); + } + + #[test] + fn extract_points_jsonpath_at_pointer_syntax() { + let input = r#"{"users":[{"name":"Ada"}]}"#; + for path in ["$.users[0].name", "users[0]"] { + let r = dispatch(&json!({"input": input, "op": "extract", "path": path})); + assert!(r.is_error, "{path}"); + assert!(r.content_text.contains("JSONPath"), "{path}: {}", r.content_text); + } + } + + #[test] + fn format_accepts_a_float_shaped_indent() { + let r = dispatch(&json!({"input": r#"{"a":1}"#, "op": "format", "indent": 4.0})); + assert!(!r.is_error); + assert!(r.content_text.contains("\n \"a\""), "{}", r.content_text); + } } diff --git a/src-tauri/src/tools/mod.rs b/src-tauri/src/tools/mod.rs index 0134c81..7323319 100644 --- a/src-tauri/src/tools/mod.rs +++ b/src-tauri/src/tools/mod.rs @@ -42,7 +42,12 @@ pub mod uuid_gen; /// generating one UUID) or reported missing when it was plainly there. /// A float is accepted only when it is exactly integral. pub fn lenient_i64(args: &serde_json::Value, key: &str) -> Option { - let v = args.get(key)?; + lenient_i64_value(args.get(key)?) +} + +/// [`lenient_i64`] for a value already in hand — a field inside a nested +/// spec rather than a top-level argument. +pub fn lenient_i64_value(v: &serde_json::Value) -> Option { if let Some(n) = v.as_i64() { return Some(n); } diff --git a/src-tauri/src/tools/pdf.rs b/src-tauri/src/tools/pdf.rs index 74751d8..f3a9f99 100644 --- a/src-tauri/src/tools/pdf.rs +++ b/src-tauri/src/tools/pdf.rs @@ -9,11 +9,13 @@ //! Unicode text: we bundle Liberation Sans (regular + bold), pre-subset to //! Latin + European + common punctuation/currency, and embed it into each //! PDF — so accented text (e.g. Polish), the euro sign, em dashes and -//! bullets render correctly. Liberation Sans is metric-compatible with -//! Helvetica/Arial, so the fixed per-character width estimate the layout -//! relies on stays valid. Glyphs outside the bundled set (e.g. CJK, emoji) -//! are replaced with `?` at render time so the file stays legible rather -//! than showing blank `.notdef` boxes. +//! bullets render correctly. Wrapping and cell truncation measure text with +//! the font's own advance widths, so a line can't run past the margin. +//! Glyphs outside the bundled Latin-only set (Cyrillic, Greek, CJK, emoji) +//! are replaced with `?` +//! at render time so the file stays legible rather than showing blank +//! `.notdef` boxes, and the tool result counts them so the model can tell +//! the user. use base64::Engine; use printpdf::*; @@ -26,7 +28,7 @@ pub const TOOL_NAME: &str = "pdf"; /// Bundled text fonts: Liberation Sans (SIL OFL — see /// `assets/fonts/LICENSE-Liberation.txt`), metric-compatible with -/// Helvetica/Arial so the fixed width estimate holds. The vendored files +/// Helvetica/Arial. The vendored files /// are pre-subset (via fonttools) to Latin + European + common /// punctuation/currency — ≈80 KB each instead of ≈410 KB — which keeps the /// `include_bytes!` blobs small in the binary itself. printpdf subsets @@ -48,12 +50,6 @@ const BODY_FONT_SIZE_PT: f32 = 11.0; /// 11 pt × 1.4 line-height ≈ 5.4 mm. Used by every block that emits a /// line of text so vertical rhythm stays consistent. const BODY_LINE_HEIGHT_MM: f32 = 5.4; -/// Conservative per-character width estimate at body size, in mm -/// (Liberation Sans ≈ Helvetica/Arial metrics). The font is proportional -/// (each glyph has its own width) so this slightly under-fills lines — -/// that's the right side to err on because over-estimating would let -/// lines overflow the right margin. -const BODY_CHAR_WIDTH_MM: f32 = 2.0; /// Spec ceiling. A single PDF spec beyond ~256 KiB is almost certainly a /// runaway prompt — text at 11 pt fits about 4000 characters per A4 page, @@ -61,7 +57,10 @@ const BODY_CHAR_WIDTH_MM: f32 = 2.0; const MAX_SPEC_BYTES: usize = 256 * 1024; /// Per-action page ceiling — bounds rasterisation cost on the receiving /// pdfjs viewer if a model decides to ask for 10 000 pages of bullet -/// lists. 200 pages is well past any real chat use. +/// lists. 200 pages is well past any real chat use. Counts physical pages: +/// the spec's `pages` are only checked against it up front, while text that +/// flows on and `page_break` blocks add pages a 256 KiB spec could +/// otherwise run into the thousands. const MAX_PAGES: usize = 200; pub fn tool_description() -> &'static str { @@ -79,7 +78,8 @@ pub fn tool_description() -> &'static str { column widths, no cell-wrap so keep cells short). \ `title` is optional and becomes the filename (sanitised). \ Unicode text (Latin, accents, common symbols) renders fine; glyphs \ - the bundled font lacks (e.g. CJK, emoji) are replaced with `?`." + the bundled font lacks (e.g. Cyrillic, Greek, CJK, emoji) are \ + replaced with `?`, and the result says how many were." } pub fn input_schema() -> Value { @@ -228,8 +228,12 @@ fn do_create(args: &Value) -> McpCallResult { pages.len() )); } - let bytes = match render_document(title_raw, &pages) { - Ok(b) => b, + let Rendered { + bytes, + page_count, + uncovered, + } = match render_document(title_raw, &pages) { + Ok(r) => r, Err(e) => return err(format!("PDF render failed: {e}")), }; let filename = build_filename(title_raw); @@ -249,16 +253,65 @@ fn do_create(args: &Value) -> McpCallResult { }; McpCallResult { content_text: format!( - "Created `{filename}` ({} page{}, {} bytes). The PDF is attached to this message — the user can preview / save it directly.", - pages.len(), - if pages.len() == 1 { "" } else { "s" }, + "Created `{filename}` ({page_count} page{}, {} bytes). The PDF is attached to this message — the user can preview / save it directly.{}", + if page_count == 1 { "" } else { "s" }, bytes.len(), + uncovered_note(&uncovered), ), is_error: false, attachments: vec![attachment], } } +/// Characters of the spec's text the bundled font has no glyph for, which +/// the PDF shows as `?`. +#[derive(Debug, Default, PartialEq)] +struct Uncovered { + count: usize, + /// The first few distinct ones, in order of appearance. + sample: Vec, +} + +/// How many distinct characters [`Uncovered::sample`] keeps. +const UNCOVERED_SAMPLE: usize = 8; + +/// The result's warning about characters printed as `?`, or nothing when +/// every one rendered — without it the model reports a clean success and +/// the user finds the `?`s. +fn uncovered_note(uncovered: &Uncovered) -> String { + if uncovered.count == 0 { + return String::new(); + } + let shown: Vec = uncovered.sample.iter().map(|c| format!("`{c}`")).collect(); + format!( + " Note: {} character{} the bundled font can't draw (e.g. {}) appear as `?` in the PDF — it covers Latin-script text only, so tell the user.", + uncovered.count, + if uncovered.count == 1 { "" } else { "s" }, + shown.join(", "), + ) +} + +/// Count what [`sanitise`] will turn into `?`, over the spec rather than as +/// it's drawn, so a table header repeated on a continued page counts once. +fn count_uncovered(pages: &[PageSpec], font: &ParsedFont) -> Uncovered { + let mut uncovered = Uncovered::default(); + for block in pages.iter().flat_map(|p| &p.blocks) { + let texts: Vec<&String> = match block { + Block::Heading { text, .. } | Block::Paragraph { text } => vec![text], + Block::BulletList { items } | Block::NumberedList { items } => items.iter().collect(), + Block::Table { headers, rows } => headers.iter().chain(rows.iter().flatten()).collect(), + Block::HorizontalRule | Block::PageBreak => Vec::new(), + }; + for c in texts.iter().flat_map(|t| t.chars()).filter(|&c| !is_covered(c, font)) { + uncovered.count += 1; + if uncovered.sample.len() < UNCOVERED_SAMPLE && !uncovered.sample.contains(&c) { + uncovered.sample.push(c); + } + } + } + uncovered +} + // ---------- spec types ---------- #[derive(Debug, Deserialize)] @@ -270,7 +323,7 @@ struct PageSpec { #[serde(tag = "type", rename_all = "snake_case")] enum Block { Heading { - #[serde(default = "default_heading_level")] + #[serde(default = "default_heading_level", deserialize_with = "lenient_level")] level: u8, text: String, }, @@ -296,14 +349,26 @@ fn default_heading_level() -> u8 { 2 } +/// `level` as models write it: the `1.0` local models emit for `1` failed +/// the whole document, where the tools' top-level integer arguments take it +/// ([`crate::tools::lenient_i64`]). +fn lenient_level<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = Value::deserialize(d)?; + crate::tools::lenient_i64_value(&v) + .and_then(|n| u8::try_from(n).ok()) + .ok_or_else(|| serde::de::Error::custom(format!("invalid heading `level` {v} — use 1, 2 or 3"))) +} + // ---------- rendering ---------- /// Carries the mutable cursor state across blocks. printpdf 0.9 is /// operation-based: a page is a `Vec`. We accumulate ops for the /// current page and flush a `PdfPage` whenever we flow onto a new one. struct RenderState<'f> { - /// Regular font, kept for glyph-coverage checks in `sanitise`. - coverage: &'f ParsedFont, + /// Parsed fonts, for glyph-coverage checks in `sanitise` (regular) and + /// for measuring text in the face it's drawn in. + regular_font: &'f ParsedFont, + bold_font: &'f ParsedFont, /// Registered font handles for emitting `SetFont` ops. regular: FontId, bold: FontId, @@ -315,32 +380,65 @@ struct RenderState<'f> { cursor_y_mm: f32, } -impl RenderState<'_> { +impl<'f> RenderState<'f> { + fn font(&self, bold: bool) -> &'f ParsedFont { + if bold { + self.bold_font + } else { + self.regular_font + } + } + + /// Whether nothing has been drawn on the page in progress yet. + fn page_is_blank(&self) -> bool { + self.ops.len() == page_init_ops().len() + } + /// Flush the in-progress page and begin a fresh one with the cursor - /// back at the top margin. - fn new_page(&mut self) { + /// back at the top margin — or refuse, if that would be one page more + /// than [`MAX_PAGES`]. + fn new_page(&mut self) -> Result<(), String> { + // A page with nothing on it yet stays the page in progress: a + // `page_break` first thing on a page, or just before the next spec + // page starts one, used to flush it blank. + if self.page_is_blank() { + self.cursor_y_mm = PAGE_HEIGHT_MM - MARGIN_MM; + return Ok(()); + } + // `pages` holds the finished ones; the page in progress is one more. + if self.pages.len() + 1 >= MAX_PAGES { + return Err(format!( + "the document runs past the {MAX_PAGES}-page cap — split it into several PDFs or shorten it" + )); + } let ops = std::mem::replace(&mut self.ops, page_init_ops()); self.pages .push(PdfPage::new(Mm(PAGE_WIDTH_MM), Mm(PAGE_HEIGHT_MM), ops)); self.cursor_y_mm = PAGE_HEIGHT_MM - MARGIN_MM; + Ok(()) } /// Reserve `space_mm` of vertical room. If the cursor would go below /// the bottom margin, flow to a new page. - fn ensure_room(&mut self, space_mm: f32) { + fn ensure_room(&mut self, space_mm: f32) -> Result<(), String> { if self.cursor_y_mm - space_mm < MARGIN_MM { - self.new_page(); + self.new_page()?; } + Ok(()) } fn advance(&mut self, dy_mm: f32) { self.cursor_y_mm -= dy_mm; } - /// Consume the state, flushing the last in-progress page. + /// Consume the state, flushing the last in-progress page — unless the + /// document ended on a `page_break` and left it blank, and it isn't the + /// only page. fn into_pages(mut self) -> Vec { - self.pages - .push(PdfPage::new(Mm(PAGE_WIDTH_MM), Mm(PAGE_HEIGHT_MM), self.ops)); + if !self.page_is_blank() || self.pages.is_empty() { + self.pages + .push(PdfPage::new(Mm(PAGE_WIDTH_MM), Mm(PAGE_HEIGHT_MM), self.ops)); + } self.pages } @@ -405,7 +503,16 @@ fn black() -> Color { }) } -fn render_document(title: &str, pages: &[PageSpec]) -> Result, String> { +/// A rendered PDF and what the result should say about it. +struct Rendered { + bytes: Vec, + /// Physical pages, after text flowed on and page breaks applied — the + /// spec's `pages` count only the ones it asked to start. + page_count: usize, + uncovered: Uncovered, +} + +fn render_document(title: &str, pages: &[PageSpec]) -> Result { let mut doc = PdfDocument::new(title); let regular = ParsedFont::from_bytes(FONT_REGULAR, 0, &mut Vec::new()) .ok_or("bundled regular font failed to parse")?; @@ -415,7 +522,8 @@ fn render_document(title: &str, pages: &[PageSpec]) -> Result, String> { let bold_id = doc.add_font(&bold); let mut state = RenderState { - coverage: ®ular, + regular_font: ®ular, + bold_font: &bold, regular: regular_id, bold: bold_id, pages: Vec::new(), @@ -428,7 +536,7 @@ fn render_document(title: &str, pages: &[PageSpec]) -> Result, String> { // in progress from initialisation. `ensure_room` may also flow onto // further pages mid-block. if page_idx > 0 { - state.new_page(); + state.new_page()?; } for block in &page.blocks { render_block(&mut state, block)?; @@ -436,10 +544,15 @@ fn render_document(title: &str, pages: &[PageSpec]) -> Result, String> { } let rendered_pages = state.into_pages(); + let page_count = rendered_pages.len(); // `PdfSaveOptions::default()` sets `subset_fonts: true`, so printpdf // trims each embedded font down to the glyphs actually used. let opts = PdfSaveOptions::default(); - Ok(doc.with_pages(rendered_pages).save(&opts, &mut Vec::new())) + Ok(Rendered { + bytes: doc.with_pages(rendered_pages).save(&opts, &mut Vec::new()), + page_count, + uncovered: count_uncovered(pages, ®ular), + }) } fn render_block(state: &mut RenderState, block: &Block) -> Result<(), String> { @@ -449,10 +562,7 @@ fn render_block(state: &mut RenderState, block: &Block) -> Result<(), String> { Block::BulletList { items } => render_list(state, items, ListStyle::Bullet), Block::NumberedList { items } => render_list(state, items, ListStyle::Numbered), Block::HorizontalRule => render_hr(state), - Block::PageBreak => { - state.new_page(); - Ok(()) - } + Block::PageBreak => state.new_page(), Block::Table { headers, rows } => render_table(state, headers, rows), } } @@ -473,13 +583,10 @@ fn render_heading(state: &mut RenderState, level: u8, text: &str) -> Result<(), 0.0 }; state.advance(top_pad_mm); - // Headings are proportionally wider than body text, so scale the - // per-char width estimate by the font-size ratio before wrapping — - // otherwise a long heading runs off the right margin. - let char_width_mm = BODY_CHAR_WIDTH_MM * size_pt / BODY_FONT_SIZE_PT; - let cleaned = sanitise(text, state.coverage); - for line in wrap_text(&cleaned, body_width_mm(), char_width_mm) { - state.ensure_room(line_mm); + // Measured in the bold face at the heading's own size — what's drawn. + let cleaned = sanitise(text, state.regular_font); + for line in wrap_text(&cleaned, body_width_mm(), state.bold_font, size_pt) { + state.ensure_room(line_mm)?; let y = state.cursor_y_mm - pt_to_mm(size_pt); state.draw_text(&line, size_pt, MARGIN_MM, y, true); state.advance(line_mm); @@ -488,9 +595,9 @@ fn render_heading(state: &mut RenderState, level: u8, text: &str) -> Result<(), } fn render_paragraph(state: &mut RenderState, text: &str) -> Result<(), String> { - let cleaned = sanitise(text, state.coverage); - for line in wrap_text(&cleaned, body_width_mm(), BODY_CHAR_WIDTH_MM) { - state.ensure_room(BODY_LINE_HEIGHT_MM); + let cleaned = sanitise(text, state.regular_font); + for line in wrap_text(&cleaned, body_width_mm(), state.regular_font, BODY_FONT_SIZE_PT) { + state.ensure_room(BODY_LINE_HEIGHT_MM)?; let y = state.cursor_y_mm - pt_to_mm(BODY_FONT_SIZE_PT); state.draw_text(&line, BODY_FONT_SIZE_PT, MARGIN_MM, y, false); state.advance(BODY_LINE_HEIGHT_MM); @@ -510,22 +617,24 @@ fn render_list( items: &[String], style: ListStyle, ) -> Result<(), String> { - let indent_mm = 6.0; - let bullet_col_mm = MARGIN_MM; - let text_col_mm = MARGIN_MM + indent_mm; - let wrap_width_mm = body_width_mm() - indent_mm; - for (i, item) in items.iter().enumerate() { - let marker = match style { + let markers: Vec = (1..=items.len()) + .map(|n| match style { ListStyle::Bullet => "•".to_string(), // Numbers wrap monotonically; we don't try to detect nested // ordering, so always the flat `1.` style. - ListStyle::Numbered => format!("{}.", i + 1), - }; - let marker = sanitise(&marker, state.coverage); - let cleaned = sanitise(item, state.coverage); - let lines = wrap_text(&cleaned, wrap_width_mm, BODY_CHAR_WIDTH_MM); + ListStyle::Numbered => format!("{n}."), + }) + .collect(); + let indent_mm = list_indent_mm(&markers, state.regular_font); + let bullet_col_mm = MARGIN_MM; + let text_col_mm = MARGIN_MM + indent_mm; + let wrap_width_mm = body_width_mm() - indent_mm; + for (item, marker) in items.iter().zip(&markers) { + let marker = sanitise(marker, state.regular_font); + let cleaned = sanitise(item, state.regular_font); + let lines = wrap_text(&cleaned, wrap_width_mm, state.regular_font, BODY_FONT_SIZE_PT); for (line_idx, line) in lines.iter().enumerate() { - state.ensure_room(BODY_LINE_HEIGHT_MM); + state.ensure_room(BODY_LINE_HEIGHT_MM)?; let y = state.cursor_y_mm - pt_to_mm(BODY_FONT_SIZE_PT); // Marker only on the first wrapped line; continuation lines // align under the text column. @@ -540,10 +649,21 @@ fn render_list( Ok(()) } +/// How far a list's text sits from the margin: 6 mm, or past its widest +/// marker with a gap to spare when that's further. A fixed 6 mm fits `•` +/// and `9.`, but from `100.` on the number ran into the item's text. +fn list_indent_mm(markers: &[String], font: &ParsedFont) -> f32 { + let widest_mm = markers + .iter() + .map(|m| text_width_mm(m, font, BODY_FONT_SIZE_PT)) + .fold(0.0, f32::max); + (widest_mm + 2.0).max(6.0) +} + fn render_hr(state: &mut RenderState) -> Result<(), String> { let pad_mm = BODY_LINE_HEIGHT_MM * 0.5; state.advance(pad_mm); - state.ensure_room(0.5); + state.ensure_room(0.5)?; let y = state.cursor_y_mm; state.draw_line(MARGIN_MM, y, PAGE_WIDTH_MM - MARGIN_MM, y); state.advance(pad_mm); @@ -570,7 +690,7 @@ fn render_table( // Headers first (if any), in bold. let has_headers = !headers.is_empty(); if has_headers { - state.ensure_room(row_height_mm); + state.ensure_room(row_height_mm)?; render_table_row(state, headers, cols, col_width_mm, cell_pad_mm, row_height_mm, true); } for row in rows { @@ -579,10 +699,10 @@ fn render_table( // so we can repeat the header band at the top of the continued // table rather than leaving the carried-over rows unlabelled. let flowed = state.cursor_y_mm - row_height_mm < MARGIN_MM; - state.ensure_room(row_height_mm); + state.ensure_room(row_height_mm)?; if flowed && has_headers { render_table_row(state, headers, cols, col_width_mm, cell_pad_mm, row_height_mm, true); - state.ensure_room(row_height_mm); + state.ensure_room(row_height_mm)?; } render_table_row(state, row, cols, col_width_mm, cell_pad_mm, row_height_mm, false); } @@ -611,8 +731,12 @@ fn render_table_row( state.draw_line(x, top_y, x, bottom_y); // Cell text — truncated to col width (no wrap inside cells). let raw = cells.get(col).map(String::as_str).unwrap_or(""); - let max_chars = ((col_width_mm - cell_pad_mm * 2.0_f32) / BODY_CHAR_WIDTH_MM).floor() as usize; - let text = truncate_to_chars(&sanitise(raw, state.coverage), max_chars); + let text = truncate_to_width( + &sanitise(raw, state.regular_font), + col_width_mm - cell_pad_mm * 2.0_f32, + state.font(bold), + BODY_FONT_SIZE_PT, + ); let text_y = top_y - cell_pad_mm - pt_to_mm(BODY_FONT_SIZE_PT); state.draw_text(&text, BODY_FONT_SIZE_PT, x + cell_pad_mm, text_y, bold); } @@ -632,50 +756,62 @@ fn render_table_row( /// `.notdef` boxes. fn sanitise(s: &str, font: &ParsedFont) -> String { s.chars() - .map(|c| { - if c.is_ascii() || font.lookup_glyph_index(c as u32).map_or(false, |g| g != 0) { - c - } else { - '?' - } - }) + .map(|c| if is_covered(c, font) { c } else { '?' }) .collect() } -/// Greedy word wrap based on a fixed per-character width estimate. The -/// bundled font is proportional, so this is conservative — lines will -/// under-fill rather than overflow. Input is assumed already sanitised. -fn wrap_text(text: &str, width_mm: f32, char_width_mm: f32) -> Vec { - let max_chars = (width_mm / char_width_mm).floor() as usize; - if max_chars == 0 { - return vec![text.to_string()]; - } +/// Whether the bundled font draws `c` — see [`sanitise`]. +fn is_covered(c: char, font: &ParsedFont) -> bool { + c.is_ascii() || font.lookup_glyph_index(c as u32).is_some_and(|g| g != 0) +} + +/// Advance width of `c` in mm at `size_pt`, from the font's own metrics. +/// printpdf lays a `ShowText` out by exactly these, with no kerning, and +/// draws a character the font doesn't map as glyph 0 (`.notdef`) — which is +/// what it is measured as here too. +fn char_width_mm(c: char, font: &ParsedFont, size_pt: f32) -> f32 { + let gid = font.lookup_glyph_index(c as u32).unwrap_or(0); + let units = font.get_glyph_width(gid).unwrap_or(0); + f32::from(units) / f32::from(font.units_per_em) * pt_to_mm(size_pt) +} + +fn text_width_mm(s: &str, font: &ParsedFont, size_pt: f32) -> f32 { + s.chars().map(|c| char_width_mm(c, font, size_pt)).sum() +} + +/// Greedy word wrap by the font's real advance widths, so no line runs +/// past `width_mm`. A fixed per-character estimate let all-caps and digit +/// runs overflow the margin (a `W` is nearly twice an average glyph). +/// Input is assumed already sanitised. +fn wrap_text(text: &str, width_mm: f32, font: &ParsedFont, size_pt: f32) -> Vec { + let space_mm = char_width_mm(' ', font, size_pt); let mut out: Vec = Vec::new(); for paragraph in text.split('\n') { let mut current = String::new(); + let mut current_mm = 0.0; for word in paragraph.split_whitespace() { + let word_mm = text_width_mm(word, font, size_pt); if current.is_empty() { // Word longer than the line — hard-break it. - if word.len() > max_chars { - for chunk in chunk_by_chars(word, max_chars) { - out.push(chunk); - } + if word_mm > width_mm { + out.extend(chunk_by_width(word, width_mm, font, size_pt)); } else { current.push_str(word); + current_mm = word_mm; } continue; } - if current.len() + 1 + word.len() <= max_chars { + if current_mm + space_mm + word_mm <= width_mm { current.push(' '); current.push_str(word); + current_mm += space_mm + word_mm; } else { out.push(std::mem::take(&mut current)); - if word.len() > max_chars { - for chunk in chunk_by_chars(word, max_chars) { - out.push(chunk); - } + if word_mm > width_mm { + out.extend(chunk_by_width(word, width_mm, font, size_pt)); } else { current.push_str(word); + current_mm = word_mm; } } } @@ -689,14 +825,20 @@ fn wrap_text(text: &str, width_mm: f32, char_width_mm: f32) -> Vec { out } -fn chunk_by_chars(s: &str, n: usize) -> Vec { +/// Split one overlong word into pieces that each fit `width_mm`. Every +/// piece takes at least one character, so this always makes progress. +fn chunk_by_width(s: &str, width_mm: f32, font: &ParsedFont, size_pt: f32) -> Vec { let mut out = Vec::new(); let mut cur = String::new(); + let mut cur_mm = 0.0; for ch in s.chars() { - cur.push(ch); - if cur.len() >= n { + let ch_mm = char_width_mm(ch, font, size_pt); + if !cur.is_empty() && cur_mm + ch_mm > width_mm { out.push(std::mem::take(&mut cur)); + cur_mm = 0.0; } + cur.push(ch); + cur_mm += ch_mm; } if !cur.is_empty() { out.push(cur); @@ -704,16 +846,27 @@ fn chunk_by_chars(s: &str, n: usize) -> Vec { out } -fn truncate_to_chars(s: &str, max: usize) -> String { - if s.chars().count() <= max { +/// `s` cut down to fit `max_mm`, measured the same way [`wrap_text`] +/// measures, so a clipped cell can't spill into its neighbour. +fn truncate_to_width(s: &str, max_mm: f32, font: &ParsedFont, size_pt: f32) -> String { + if text_width_mm(s, font, size_pt) <= max_mm { return s.to_string(); } - if max <= 1 { - return s.chars().take(max).collect(); + // Append `~` as the clipped-cell hint, which needs room of its own; a + // column too narrow for even that stays empty. + let budget_mm = max_mm - char_width_mm('~', font, size_pt); + if budget_mm < 0.0 { + return String::new(); + } + let mut out = String::new(); + let mut used_mm = 0.0; + for ch in s.chars() { + used_mm += char_width_mm(ch, font, size_pt); + if used_mm > budget_mm { + break; + } + out.push(ch); } - // Append `~` as the clipped-cell hint — kept as plain ASCII so the - // marker width stays predictable (a real `…` would render fine now too). - let mut out: String = s.chars().take(max - 1).collect(); out.push('~'); out } @@ -726,18 +879,51 @@ fn pt_to_mm(pt: f32) -> f32 { pt * 0.352_777_8 } +/// Longest the filename's stem may be, in UTF-8 bytes. Linux and macOS cap a +/// name at 255 bytes, Windows at 255 UTF-16 units (which never outnumber the +/// UTF-8 bytes), and this leaves room for `.pdf` and the ` (1)` a download +/// gets when the name is taken. +const MAX_FILENAME_STEM_BYTES: usize = 120; + +/// The title as a filename: letters and digits of any script, `-` and `_`, +/// with whitespace as `_`. Letters are safe in a name on Windows, macOS and +/// Linux alike — keeping only ASCII ones turned `Zażółć gęślą jaźń` into +/// `Za_gl_ja` — while separators, `:`, dots, control and text-direction +/// characters are not, and are dropped. Cut to [`MAX_FILENAME_STEM_BYTES`]. +/// A Windows device name gets a trailing `_` (see [`is_windows_device_name`]). fn build_filename(raw_title: &str) -> String { let mut slug = String::with_capacity(raw_title.len()); - for ch in raw_title.chars() { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - slug.push(ch); + for ch in raw_title.trim().chars() { + let kept = if ch.is_alphanumeric() || ch == '-' || ch == '_' { + ch } else if ch.is_whitespace() { - slug.push('_'); + '_' + } else { + continue; + }; + if slug.len() + kept.len_utf8() > MAX_FILENAME_STEM_BYTES { + break; } + slug.push(kept); } let slug = slug.trim_matches('_').to_string(); let base = if slug.is_empty() { "document" } else { &slug }; - format!("{base}.pdf") + let suffix = if is_windows_device_name(base) { "_" } else { "" }; + format!("{base}{suffix}.pdf") +} + +/// Whether Windows takes `stem` for a device — `CON`, `PRN`, `AUX`, `NUL`, +/// `COM1`–`COM9`, `LPT1`–`LPT9` and the superscript `COM¹`–`LPT³`, in any +/// case — rather than a file. Windows 10 does so with an extension too, so +/// saving `con.pdf` there writes to the console instead of a file. +fn is_windows_device_name(stem: &str) -> bool { + let upper = stem.to_ascii_uppercase(); + matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (matches!(upper.get(..3), Some("COM" | "LPT")) + && matches!( + &upper[3..], + "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³" + )) } fn err(msg: impl Into) -> McpCallResult { @@ -906,42 +1092,224 @@ mod tests { assert!(decoded.starts_with(b"%PDF-"), "expected a valid PDF"); } + fn fonts() -> (ParsedFont, ParsedFont) { + let parse = |bytes| ParsedFont::from_bytes(bytes, 0, &mut Vec::new()).expect("bundled font parses"); + (parse(FONT_REGULAR), parse(FONT_BOLD)) + } + #[test] fn wrap_text_respects_width() { - // 60 mm wide, ~2 mm per char → ~30 chars per line. + let (regular, _) = fonts(); let wrapped = wrap_text( "the quick brown fox jumps over the lazy dog and then some more words", 60.0, - 2.0, + ®ular, + BODY_FONT_SIZE_PT, ); assert!(wrapped.len() >= 2, "expected multi-line wrap, got: {wrapped:?}"); for line in &wrapped { - assert!(line.len() <= 30, "line too long: `{line}` ({} chars)", line.len()); + let w = text_width_mm(line, ®ular, BODY_FONT_SIZE_PT); + assert!(w <= 60.0, "line too long: `{line}` ({w} mm)"); + } + } + + /// Wide glyphs are where a fixed 2 mm/char estimate overflowed: 85 + /// characters "fit" the 170 mm body width, but 85 capitals at 11 pt run + /// to ~250 mm and 85 `W`s to over 300 mm. + #[test] + fn wide_text_wraps_inside_the_margin() { + let (regular, bold) = fonts(); + let caps = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG ".repeat(4); + let digits = "1234567890 ".repeat(12); + let ws = "W".repeat(85); + for text in [caps.as_str(), digits.as_str(), ws.as_str()] { + for (font, size) in [(®ular, BODY_FONT_SIZE_PT), (&bold, 20.0)] { + let lines = wrap_text(text, body_width_mm(), font, size); + assert!(lines.len() >= 2, "{text:?} at {size} pt should wrap: {lines:?}"); + for line in &lines { + let w = text_width_mm(line, font, size); + assert!(w <= body_width_mm(), "`{line}` is {w} mm at {size} pt"); + } + assert_eq!(lines.concat().replace(' ', ""), text.replace(' ', ""), "nothing lost"); + } } } #[test] - fn truncate_to_chars_adds_hint_when_clipping() { + fn truncate_to_width_adds_hint_when_clipping() { + let (regular, _) = fonts(); + let w = |s: &str| text_width_mm(s, ®ular, BODY_FONT_SIZE_PT); // Fits within the budget — returned unchanged. - assert_eq!(truncate_to_chars("hi", 5), "hi"); - // Clipped — keeps `max - 1` chars and appends the ASCII `~` hint, - // landing exactly on the budget. - let t = truncate_to_chars("abcdefgh", 4); + assert_eq!(truncate_to_width("hi", w("hi"), ®ular, BODY_FONT_SIZE_PT), "hi"); + // Clipped — keeps what fits beside the ASCII `~` hint. + let t = truncate_to_width("abcdefgh", w("abc~"), ®ular, BODY_FONT_SIZE_PT); assert_eq!(t, "abc~"); - assert_eq!(t.chars().count(), 4); + // Measured, not counted: four capitals take more room than four + // lowercase letters, so fewer of them fit the same width. + let t = truncate_to_width("WWWWWWWW", w("abc~"), ®ular, BODY_FONT_SIZE_PT); + assert!(w(&t) <= w("abc~"), "`{t}` overflows"); + assert!(t.chars().count() < 4, "{t}"); // Degenerate widths must not panic. - assert_eq!(truncate_to_chars("abc", 0), ""); - assert_eq!(truncate_to_chars("abc", 1), "a"); + assert_eq!(truncate_to_width("abc", 0.0, ®ular, BODY_FONT_SIZE_PT), ""); + assert_eq!(truncate_to_width("abc", -3.0, ®ular, BODY_FONT_SIZE_PT), ""); } #[test] fn wrap_text_hard_breaks_overlong_word() { // A single token longer than the line is chunked rather than - // overflowing the right margin. 10 mm / 2 mm per char ≈ 5 chars. - let wrapped = wrap_text("aaaaaaaaaaaaaaaaaaaa", 10.0, 2.0); + // overflowing the right margin. + let (regular, _) = fonts(); + let word = "a".repeat(20); + let wrapped = wrap_text(&word, 10.0, ®ular, BODY_FONT_SIZE_PT); assert!(wrapped.len() >= 2, "expected a hard break, got: {wrapped:?}"); for line in &wrapped { - assert!(line.len() <= 5, "chunk too long: `{line}`"); + assert!(text_width_mm(line, ®ular, BODY_FONT_SIZE_PT) <= 10.0, "chunk too long: `{line}`"); + } + assert_eq!(wrapped.concat(), word); + } + + /// Characters the bundled subset lacks used to become `?` behind a plain + /// "Created …"; the result now says how many, and which. + #[test] + fn unrenderable_characters_are_reported() { + let r = dispatch(&json!({ + "action": "create", + "pages": [{ "blocks": [ + { "type": "heading", "text": "Ωμέγα" }, + { "type": "paragraph", "text": "中文, but Łódź is fine" }, + { "type": "table", "headers": ["Ж"], "rows": [["Ж"]] } + ]}] + })); + assert!(!r.is_error, "{}", r.content_text); + assert!(r.content_text.contains("9 characters"), "{}", r.content_text); + for c in ["`Ω`", "`α`", "`中`", "`Ж`"] { + assert!(r.content_text.contains(c), "{c} missing: {}", r.content_text); + } + assert!(!r.content_text.contains("`Ł`"), "{}", r.content_text); + assert!(!create_minimal().content_text.contains("Note:")); + let font = fonts().0; + let cyrillic = [PageSpec { blocks: vec![Block::Paragraph { text: "Привет мир".into() }] }]; + assert_eq!( + count_uncovered(&cyrillic, &font), + Uncovered { count: 9, sample: vec!['П', 'р', 'и', 'в', 'е', 'т', 'м'] }, + "every occurrence counts, each character is named once, the space renders" + ); + } + + /// The cap and the reported count are physical pages: page breaks and + /// text that flows on add pages the spec's `pages` array doesn't show. + #[test] + fn physical_pages_are_counted_and_capped() { + // `n` pages of text, a page break between each two. + let paged = |n: usize| { + let text = json!({ "type": "paragraph", "text": "x" }); + let mut blocks = vec![text.clone()]; + for _ in 1..n { + blocks.extend([json!({ "type": "page_break" }), text.clone()]); + } + blocks + }; + let r = dispatch(&json!({ "action": "create", "pages": [{ "blocks": paged(3) }] })); + assert!(!r.is_error, "{}", r.content_text); + assert!(r.content_text.contains("(3 pages,"), "{}", r.content_text); + + let lines = "line\n".repeat(100); + let r = dispatch(&json!({ + "action": "create", + "pages": [{ "blocks": [{ "type": "paragraph", "text": lines }] }] + })); + assert!(!r.is_error, "{}", r.content_text); + assert!(r.content_text.contains("(3 pages,"), "{}", r.content_text); + + let r = dispatch(&json!({ "action": "create", "pages": [{ "blocks": paged(MAX_PAGES + 1) }] })); + assert!(r.is_error, "{}", r.content_text); + assert!(r.content_text.contains("cap"), "{}", r.content_text); + let r = dispatch(&json!({ "action": "create", "pages": [{ "blocks": paged(MAX_PAGES) }] })); + assert!(!r.is_error, "{}", r.content_text); + assert!(r.content_text.contains(&format!("({MAX_PAGES} pages,")), "{}", r.content_text); + } + + /// A page break with nothing before it on its page — first thing in the + /// document, straight after another break, or the last block before the + /// next spec page — doesn't put out a blank page; nor does one at the end. + #[test] + fn page_breaks_never_leave_a_blank_page() { + let text = || json!({ "type": "paragraph", "text": "x" }); + let brk = || json!({ "type": "page_break" }); + let pages_in = |pages: Value| { + let r = dispatch(&json!({ "action": "create", "pages": pages })); + assert!(!r.is_error, "{}", r.content_text); + r.content_text + }; + for (pages, want) in [ + (json!([{ "blocks": [brk(), text()] }]), "(1 page,"), + (json!([{ "blocks": [text(), brk(), brk(), text()] }]), "(2 pages,"), + (json!([{ "blocks": [text(), brk()] }, { "blocks": [text()] }]), "(2 pages,"), + (json!([{ "blocks": [text(), brk()] }]), "(1 page,"), + // Nothing drawn at all is still a one-page PDF. + (json!([{ "blocks": [brk()] }]), "(1 page,"), + ] { + let got = pages_in(pages.clone()); + assert!(got.contains(want), "{pages}: {got}"); + } + } + + #[test] + fn heading_level_accepts_a_float_shaped_integer() { + let heading = |level: Value| { + dispatch(&json!({ + "action": "create", + "pages": [{ "blocks": [{ "type": "heading", "level": level, "text": "Title" }] }] + })) + }; + let r = heading(json!(1.0)); + assert!(!r.is_error, "{}", r.content_text); + let r = heading(json!(1.5)); + assert!(r.is_error, "{}", r.content_text); + assert!(r.content_text.contains("level"), "{}", r.content_text); + } + + #[test] + fn filenames_keep_letters_of_any_script_and_stay_short() { + assert_eq!(build_filename("Zażółć gęślą jaźń"), "Zażółć_gęślą_jaźń.pdf"); + assert_eq!(build_filename("Отчёт: 2026/Q4"), "Отчёт_2026Q4.pdf"); + assert_eq!(build_filename("🦊 \u{202E}"), "document.pdf"); + let long = build_filename(&"ż".repeat(300)); + assert!(long.len() <= MAX_FILENAME_STEM_BYTES + ".pdf".len(), "{}", long.len()); + assert!(long.starts_with("żżż") && long.ends_with("ż.pdf"), "{long}"); + } + + #[test] + fn filenames_never_name_a_windows_device() { + for (title, name) in [ + ("CON", "CON_.pdf"), + ("nul", "nul_.pdf"), + (" Aux ", "Aux_.pdf"), + ("Com1", "Com1_.pdf"), + ("lpt9", "lpt9_.pdf"), + ("COM²", "COM²_.pdf"), + // Longer names and other digits are ordinary files. + ("CONSOLE", "CONSOLE.pdf"), + ("COM10", "COM10.pdf"), + ("LPT0", "LPT0.pdf"), + ("con.pdf", "conpdf.pdf"), + ] { + assert_eq!(build_filename(title), name, "{title:?}"); + } + } + + /// From `100.` on, a number is wider than the old fixed 6 mm indent and + /// ran into the item's text. + #[test] + fn list_text_clears_its_widest_marker() { + let (regular, _) = fonts(); + let numbered = |n: usize| (1..=n).map(|i| format!("{i}.")).collect::>(); + assert_eq!(list_indent_mm(&["•".to_string()], ®ular), 6.0); + assert_eq!(list_indent_mm(&numbered(9), ®ular), 6.0); + for n in [100, 1000] { + let marker_mm = text_width_mm(&format!("{n}."), ®ular, BODY_FONT_SIZE_PT); + let indent = list_indent_mm(&numbered(n), ®ular); + assert!(indent > marker_mm + 1.0, "{n}: {indent} mm for a {marker_mm} mm marker"); } } diff --git a/src-tauri/src/tools/sort_tool.rs b/src-tauri/src/tools/sort_tool.rs index 47fb287..1db60dc 100644 --- a/src-tauri/src/tools/sort_tool.rs +++ b/src-tauri/src/tools/sort_tool.rs @@ -25,8 +25,9 @@ pub fn tool_description() -> &'static str { `numeric` (parses each line as a number and sorts numerically — lines \ that don't parse sort after the numbers in their original order). \ Flags: `reverse` (descending order), `unique` (collapse adjacent \ - duplicates after sort, like `sort -u`), `case_sensitive` (default true; \ - when false, lexical and natural compare case-insensitively)." + duplicates after sort, like `sort -u`; ignoring case when \ + `case_sensitive` is false, like `sort -fu`), `case_sensitive` (default \ + true; when false, lexical and natural compare case-insensitively)." } pub fn input_schema() -> Value { @@ -70,11 +71,11 @@ pub fn dispatch(args: &Value) -> McpCallResult { if case_sensitive { lines.sort(); } else { - // `sort_by_key` lowercases each line once and caches the key - // — `sort_by` would re-lowercase both sides on every - // comparison (O(n log n) extra allocations). Same observable - // ordering either way. - lines.sort_by_key(|a| a.to_lowercase()); + // `sort_by_cached_key` lowercases each line once and caches + // the key — `sort_by_key` and `sort_by` would re-lowercase + // both sides on every comparison (O(n log n) extra + // allocations). Same observable ordering either way. + lines.sort_by_cached_key(|a| a.to_lowercase()); } } "natural" => { @@ -103,6 +104,9 @@ pub fn dispatch(args: &Value) -> McpCallResult { }) .collect(); decorated.sort_by(|a, b| match (a.1, b.1) { + // `reverse` flips the numbers alone: reversing the whole list + // put the unparseable lines first, against the description. + (Some(x), Some(y)) if reverse => y.total_cmp(&x), (Some(x), Some(y)) => x.total_cmp(&y), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, @@ -112,11 +116,17 @@ pub fn dispatch(args: &Value) -> McpCallResult { } other => return err(format!("unknown mode `{other}`")), } - if reverse { + if reverse && mode != "numeric" { lines.reverse(); } if unique { - lines.dedup(); + // Case-insensitive sorts put `Apple` next to `apple`; collapse + // those too, as `sort -fu` does, rather than keep both. + if !case_sensitive && mode != "numeric" { + lines.dedup_by(|a, b| a.to_lowercase() == b.to_lowercase()); + } else { + lines.dedup(); + } } McpCallResult { content_text: lines.join("\n"), @@ -209,4 +219,27 @@ mod tests { let r = dispatch(&json!({"input": "a\nb", "mode": "alphabet"})); assert!(r.is_error); } + + #[test] + fn case_insensitive_unique_collapses_case_variants() { + for mode in ["lexical", "natural"] { + let r = dispatch(&json!({ + "input": "banana\nApple\napple\nAPPLE", + "mode": mode, + "unique": true, + "case_sensitive": false, + })); + assert!(!r.is_error); + assert_eq!(r.content_text, "Apple\nbanana", "{mode}"); + } + } + + /// Lines that don't parse stay after the numbers, in input order, when + /// the numbers are reversed too. + #[test] + fn reverse_numeric_keeps_unparseable_lines_last() { + let r = dispatch(&json!({"input": "x\n2\ny\n10\n1", "mode": "numeric", "reverse": true})); + assert!(!r.is_error); + assert_eq!(r.content_text, "10\n2\n1\nx\ny"); + } } diff --git a/src-tauri/src/tools/unit_convert.rs b/src-tauri/src/tools/unit_convert.rs index b7097ed..55cfcb6 100644 --- a/src-tauri/src/tools/unit_convert.rs +++ b/src-tauri/src/tools/unit_convert.rs @@ -213,6 +213,18 @@ const UNITS: &[Unit] = &[ Unit { name: "kelvin", category: Category::Temperature, factor: 0.0 }, ]; +/// Spellings whose case is the prefix — `M` mega, `m` milli — matched before +/// case is folded, which read `ML` as `mL` and `mPa` as `MPa`, 10⁹ off. The +/// all-lower and all-upper spellings carry no such signal and still fold. +const CASED_UNITS: &[Unit] = &[ + Unit { name: "Mm", category: Category::Length, factor: 1.0e6 }, + Unit { name: "Mg", category: Category::Mass, factor: 1_000.0 }, + Unit { name: "ML", category: Category::Volume, factor: 1.0e6 }, + Unit { name: "Ml", category: Category::Volume, factor: 1.0e6 }, + Unit { name: "Ms", category: Category::Time, factor: 1.0e6 }, + Unit { name: "mPa", category: Category::Pressure, factor: 1.0e-3 }, +]; + pub fn tool_description() -> &'static str { "Convert a numeric value from one unit to another. Use this rather \ than guessing a conversion factor — your answer will drift for \ @@ -226,8 +238,11 @@ pub fn tool_description() -> &'static str { area (m2, cm2, km2, ft2, in2, yd2, mi2, acre, hectare), \ energy (J, kJ, cal, kcal, Wh, kWh, BTU), \ pressure (Pa, kPa, MPa, bar, psi, atm, mmHg, torr), \ - temperature (C, F, K). Names are case-insensitive. Both units must \ - belong to the same category." + temperature (C, F, K — `°C` works too). Names are case-insensitive, \ + except a capital `M` for mega against a small `m` for milli (`ML` is \ + megalitres, `mL` millilitres; `MPa` and `mPa`), and `Cal`: the food \ + Calorie, a kcal, where `cal` is the small calorie. Plurals (`kilograms`, `hours`) work. Both units must belong to \ + the same category." } pub fn input_schema() -> Value { @@ -290,15 +305,39 @@ pub fn dispatch(args: &Value) -> McpCallResult { return err("conversion result is out of range (overflowed)"); } McpCallResult { - content_text: format_number(result), + content_text: super::calculate::format_number(result), is_error: false, ..Default::default() } } fn find_unit(name: &str) -> Option<&'static Unit> { - let needle = name.trim().to_ascii_lowercase(); - UNITS.iter().find(|u| u.name == needle) + let name = name.trim(); + // The degree sign names the same scales as the bare letters: `°C`, or + // the one-character `℃` / `℉`. + let scale = match name { + "℃" => Some("C"), + "℉" => Some("F"), + _ => name.strip_prefix('°'), + }; + if let Some(scale) = scale { + return find_unit(scale).filter(|u| u.category == Category::Temperature); + } + // `Cal` is the food Calorie, a kilocalorie: only the capital tells it + // from the `cal` a thousand times smaller, so it's matched before case + // is folded. + if matches!(name, "Cal" | "Cals") { + return find_unit("kcal"); + } + if let Some(u) = CASED_UNITS.iter().find(|u| u.name == name) { + return Some(u); + } + let needle = name.to_ascii_lowercase(); + UNITS.iter().find(|u| u.name == needle).or_else(|| { + // Regular plurals — `kilograms`, `hours`, `mins` — of any name. + let singular = needle.strip_suffix('s')?; + UNITS.iter().find(|u| u.name == singular) + }) } /// Affine temperature conversion, routed through Kelvin so each pair is @@ -310,6 +349,9 @@ fn convert_temperature(value: f64, from: &str, to: &str) -> Result "k" | "kelvin" => value, other => return Err(format!("unknown temperature unit `{other}`")), }; + if kelvin < 0.0 { + return Err(format!("{value} {from} is below absolute zero (0 K)")); + } Ok(match to { "c" | "celsius" => kelvin - 273.15, "f" | "fahrenheit" => kelvin * 9.0 / 5.0 - 459.67, @@ -318,25 +360,6 @@ fn convert_temperature(value: f64, from: &str, to: &str) -> Result }) } -/// Same trick as `calculate::format_result`: integer-valued floats become -/// `N`, otherwise trim trailing zeros from a 12-digit display so the -/// output reads cleanly. -fn format_number(v: f64) -> String { - if v.fract() == 0.0 && v.abs() < 1e15 { - return format!("{}", v as i64); - } - let s = format!("{v:.12}"); - let trimmed = s.trim_end_matches('0').trim_end_matches('.'); - // `v` is non-zero here (an exact zero took the integer branch above), so - // a result that rounds away at 12 decimals — 0.0001 mg in tonnes, say — - // must not be reported as a flat "0"/"-0". Surface those in scientific - // notation, the same guard `calculate::format_result` already carries. - if trimmed.is_empty() || trimmed == "-" || trimmed == "0" || trimmed == "-0" { - return format!("{v:e}"); - } - trimmed.to_string() -} - fn err(msg: impl Into) -> McpCallResult { McpCallResult { content_text: msg.into(), @@ -408,4 +431,92 @@ mod tests { assert!(!r.is_error); assert_eq!(r.content_text, "660"); } + + fn convert(value: f64, from: &str, to: &str) -> McpCallResult { + dispatch(&json!({"value": value, "from": from, "to": to})) + } + + /// Folding case read the food `Cal` as the small `cal`, 1000× off. + #[test] + fn capital_cal_is_the_food_calorie() { + assert_eq!(convert(1.0, "Cal", "kcal").content_text, "1"); + assert_eq!(convert(2.0, "Cals", "kJ").content_text, "8.368"); + assert_eq!(convert(1000.0, "cal", "kcal").content_text, "1"); + assert_eq!(convert(1.0, "CAL", "J").content_text, "4.184"); + } + + /// Folding case read `ML` as `mL` and `mPa` as `MPa`, 10⁹ off. + #[test] + fn case_tells_mega_from_milli() { + for (from, to, want) in [ + ("ML", "L", "1000000"), + ("Ml", "L", "1000000"), + ("mL", "L", "0.001"), + ("ml", "L", "0.001"), + ("MPa", "Pa", "1000000"), + ("mPa", "Pa", "0.001"), + ("Mg", "kg", "1000"), + ("mg", "g", "0.001"), + ("Mm", "km", "1000"), + ("Ms", "s", "1000000"), + ("MS", "s", "0.001"), + // No case signal: still folded onto the table's spelling. + ("mpa", "kPa", "1000"), + ("MM", "m", "0.001"), + ] { + let r = convert(1.0, from, to); + assert!(!r.is_error, "{from} → {to}: {}", r.content_text); + assert_eq!(r.content_text, want, "{from} → {to}"); + } + } + + #[test] + fn degree_signs_name_temperature_scales() { + for (from, to) in [("°C", "°F"), ("℃", "℉"), ("° C", "F")] { + let r = convert(100.0, from, to); + assert!(!r.is_error, "{from} → {to}: {}", r.content_text); + assert_eq!(r.content_text, "212", "{from} → {to}"); + } + assert!(convert(1.0, "°m", "m").is_error, "the degree sign is for temperatures only"); + } + + #[test] + fn regular_plurals_are_accepted() { + for (from, to, want) in [ + ("kilograms", "grams", "1000"), + ("hours", "minutes", "60"), + ("mins", "secs", "60"), + ("Miles", "yards", "1760"), + ] { + let r = convert(1.0, from, to); + assert!(!r.is_error, "{from} → {to}: {}", r.content_text); + assert_eq!(r.content_text, want, "{from} → {to}"); + } + } + + #[test] + fn temperatures_below_absolute_zero_are_refused() { + for (value, from) in [(-273.16, "C"), (-460.0, "F"), (-1.0, "K")] { + let r = convert(value, from, "K"); + assert!(r.is_error, "{value} {from}: {}", r.content_text); + assert!(r.content_text.contains("absolute zero"), "{}", r.content_text); + } + assert_eq!(convert(-273.15, "C", "K").content_text, "0"); + } + + /// 12 significant digits, as `calculate` prints: a tiny result keeps its + /// digits and a huge one doesn't print f64 noise past them. + #[test] + fn results_keep_twelve_significant_digits() { + for (value, from, to, want) in [ + (5.1, "mg", "t", "5.1e-9"), + (1.0, "ly", "km", "9.46073047258e12"), + (1.0, "pc", "m", "3.08567758149e16"), + (1.0, "m", "ft", "3.28083989501"), + ] { + let r = dispatch(&json!({"value": value, "from": from, "to": to})); + assert!(!r.is_error, "{}", r.content_text); + assert_eq!(r.content_text, want, "{value} {from} → {to}"); + } + } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3045ccc..5b8e3d7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Loach", - "version": "1.5.0", + "version": "1.6.0", "identifier": "dev.loach.app", "build": { "beforeDevCommand": "npm run dev", @@ -27,11 +27,12 @@ "visible": true, "shadow": true, "dragDropEnabled": false, - "backgroundColor": "#06070f" + "backgroundColor": "#06070f", + "additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --force-webrtc-ip-handling-policy=disable_non_proxied_udp" } ], "security": { - "csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost" + "csp": "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost; frame-src loach-sandbox: http://loach-sandbox.localhost" } }, "bundle": { @@ -54,6 +55,9 @@ } }, "plugins": { + "shell": { + "open": "((https?://\\w+)|(mailto:\\w+)).+" + }, "updater": { "endpoints": [ "https://github.com/ztcs-software/loach/releases/latest/download/latest.json" diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..160b723 --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [ + { + "label": "main", + "title": "Loach", + "width": 1280, + "height": 820, + "minWidth": 900, + "minHeight": 600, + "decorations": true, + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "trafficLightPosition": { "x": 14, "y": 20 }, + "transparent": false, + "resizable": true, + "fullscreen": false, + "center": true, + "visible": true, + "shadow": true, + "dragDropEnabled": false, + "backgroundColor": "#06070f", + "additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --force-webrtc-ip-handling-policy=disable_non_proxied_udp" + } + ] + } +} diff --git a/src/App.tsx b/src/App.tsx index b47743c..e417f14 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,8 +20,12 @@ const SpaceView = lazy(() => const SpacesLibrary = lazy(() => import("@/components/SpacesLibrary").then((m) => ({ default: m.SpacesLibrary })), ); +const LibraryView = lazy(() => + import("@/components/library/LibraryView").then((m) => ({ default: m.LibraryView })), +); import { ShareDialog } from "@/components/ShareDialog"; import { SnippetDialog } from "@/components/SnippetDialog"; +import { ItemPreviewDialog, TextEditorDialog } from "@/components/library/LibraryDialogs"; import { SnippetVariableDialog } from "@/components/SnippetVariableDialog"; import { SnippetVariableFillDialog } from "@/components/SnippetVariableFillDialog"; const SnippetsLibrary = lazy(() => @@ -52,6 +56,7 @@ const CodeCanvas = lazy(() => function warmLazyChunks() { void import("@/components/SpaceView"); void import("@/components/SpacesLibrary"); + void import("@/components/library/LibraryView"); void import("@/components/SnippetsLibrary"); void import("@/components/ModelsView"); void import("@/components/ModelsLibrary"); @@ -69,10 +74,13 @@ import { Button } from "@/components/ui/button"; import { SquarePen } from "lucide-react"; import { resolveDefaultModelChoice, useChatStore } from "@/stores/chatStore"; import { useModelsStore } from "@/stores/modelsStore"; +import { useMcpStore } from "@/stores/mcpStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { chatCancelAll, ollamaPreloadModel, ollamaStart } from "@/lib/tauri"; import { logger } from "@/lib/logger"; +import { restoreMemoryTurns } from "@/lib/memory"; import { useSnippetStore } from "@/stores/snippetStore"; +import { useLibraryStore } from "@/stores/libraryStore"; import { useSnippetVarStore } from "@/stores/snippetVarStore"; import { useSpaceStore } from "@/stores/spaceStore"; import { useSecurityStore, lockUntilHydrated } from "@/stores/securityStore"; @@ -91,14 +99,20 @@ export default function App() { const hydrateChats = useChatStore((s) => s.hydrate); const hydrateSpaces = useSpaceStore((s) => s.hydrate); const hydrateSnippets = useSnippetStore((s) => s.hydrate); + const hydrateLibrary = useLibraryStore((s) => s.hydrate); const hydrateSnippetVars = useSnippetVarStore((s) => s.hydrate); const hydrateModels = useModelsStore((s) => s.hydrate); + const hydrateMcp = useMcpStore((s) => s.hydrate); const hydrateSecurity = useSecurityStore((s) => s.hydrate); const securityHydrated = useSecurityStore((s) => s.hydrated); const securityConfigured = useSecurityStore((s) => s.status.configured); const unlocked = useSecurityStore((s) => s.unlocked); const backgroundStyle = useSettingsStore((s) => s.background_style); const settingsHydrated = useSettingsStore((s) => s.hydrated); + // A failed settings read leaves the defaults in place — including + // `onboarding_completed: false` — so it must not pass for a fresh install: + // onboarding would overwrite the user's stored prompt and URLs. + const settingsLoadError = useSettingsStore((s) => s.loadError); const onboardingCompleted = useSettingsStore( (s) => s.onboarding_completed, ); @@ -152,11 +166,38 @@ export default function App() { chatCancelAll().catch((e) => logger.warn("couldn't stop leftover chat streams", e)); }, []); + // A file dropped where nothing accepts it (the Spaces or Snippets page, a + // model's details, "No chat open", the lock screen) reached the webview's + // default handling, which opens the file in place of the app — Tauri's + // drag-drop is off, so nothing else catches it. Only cancel the default: + // the composer's and the Library's own drop handlers still do the work. + useEffect(() => { + const guard = (e: DragEvent) => { + if (e.dataTransfer?.types.includes("Files")) e.preventDefault(); + }; + window.addEventListener("dragover", guard); + window.addEventListener("drop", guard); + return () => { + window.removeEventListener("dragover", guard); + window.removeEventListener("drop", guard); + }; + }, []); + // Re-lock triggers (idle timeout / minimize). Self-gating: does nothing // until a lock is configured AND the user has opted into a trigger, so // it costs nothing for the installs that never set a lock up. useAutoLock(); + // Locking ends a Private Chat session the way closing it does. The + // overlay unmounts behind the lock screen anyway; keeping its transcript + // for whoever unlocks contradicted "nothing outlives the session", and its + // stream kept running while the regular chat queue was released. + useEffect(() => { + if (!unlocked && usePrivateChatStore.getState().open) { + usePrivateChatStore.getState().wipe(); + } + }, [unlocked]); + // See `warmLazyChunks`. `requestIdleCallback` is missing on the oldest // webviews we support (Safari gained it in 17.4), hence the timeout path. useEffect(() => { @@ -168,18 +209,25 @@ export default function App() { return () => window.clearTimeout(t); }, []); + const phase2Started = useRef(false); // Phase 2 — once we're past the lock screen, hydrate the rest of the app. // Doing this after unlock keeps the lock surface snappy and avoids // shipping any chat data into memory while the user is still proving // they're allowed to see it. // - // The five hydrates are independent — none of their `hydrate()` methods - // reads from another store — so we fan them out in parallel. The prior + // The hydrates are independent — except `hydrateModels`, whose listing + // reads the provider URLs and the key flag from the settings store, so it + // waits for settings — and we fan the rest out in parallel. The prior // sequential await chain was the dominant chunk of post-unlock latency, // since IPC round-trips stack additively. Parallelising collapses them // to the slowest single call (typically `hydrateModels`, which is // network-bound to Ollama). // + // Runs once per launch. Every later unlock (idle lock, lock-now, a macOS + // window close) re-mounts the app over stores that are still loaded, and + // re-running this used to re-cull empty chats, jump the selection and + // repeat auto-launch and preload on each one. + // // The Ollama preload only needs `settings` + `sessions` to resolve the // default model choice, so we kick it off as soon as those two land // rather than waiting for spaces/snippets/models. That shaves the @@ -195,15 +243,35 @@ export default function App() { // the same atomic update that sets the correct `unlocked`, so gating on it // closes that window. if (!securityHydrated || !unlocked) return; + if (phase2Started.current) return; + phase2Started.current = true; const settingsP = hydrateSettings(); const chatsP = hydrateChats(); - void hydrateSpaces(); + const spacesP = hydrateSpaces(); void hydrateSnippets(); void hydrateSnippetVars(); + void hydrateLibrary(); + // `/tools` and `/list mcp` read the server list from this store, which + // used to load only when Settings → MCP mounted — so until then they + // reported "No MCP servers" to anyone who had some. + void hydrateMcp(); // `hydrateModels` is network-bound (Ollama /api/tags) — fire-and-forget - // so its latency never blocks anything the user sees. - void hydrateModels(); + // so its latency never blocks anything the user sees. It lists from the + // configured URLs, so it starts once settings land: started alongside + // them, it listed the defaults (localhost Ollama, no OpenAI) and stayed + // that way for anyone with a remote server or an API key. + void settingsP.then(() => hydrateModels()); + // Memory-extraction turns still queued when the app last quit. Checked + // against the chats and against each scope's memory switch — a Space's + // or the global setting — so it waits for all three. + void Promise.all([settingsP, chatsP, spacesP]) + .then(() => + restoreMemoryTurns((id) => + useChatStore.getState().sessions.find((s) => s.id === id), + ), + ) + .catch((e) => logger.warn("couldn't restore queued memory turns", e)); // Optional Ollama auto-launch. Starts `ollama serve` if nothing is // answering, so a user who lives in Loach doesn't have to keep the @@ -272,8 +340,10 @@ export default function App() { hydrateSpaces, hydrateSnippets, hydrateSnippetVars, + hydrateLibrary, hydrateChats, hydrateModels, + hydrateMcp, ]); // Narrow-window squeeze relief. At the 900 px minimum, the expanded @@ -309,7 +379,7 @@ export default function App() { // chat surface but below the TitleBar so window controls keep // working — same pattern as LockScreen. const showOnboarding = - !showLock && !probing && settingsHydrated && !onboardingCompleted; + !showLock && !probing && settingsHydrated && !settingsLoadError && !onboardingCompleted; return ( <> @@ -361,32 +431,42 @@ export default function App() { extractor. */}
+ {/* Every boundary in this slot is keyed by what it shows. They all + sit at the same position, so unkeyed React kept one instance + across them and a crash's fallback stayed up on the next page, + Space, model or chat the user moved to. */} {viewingSpaceId ? ( - + ) : viewingModel ? ( - + ) : sidebarTab === "spaces" ? ( - + + ) : sidebarTab === "library" ? ( + + + + + ) : sidebarTab === "snippets" ? ( - + ) : sidebarTab === "models" ? ( - + @@ -397,7 +477,7 @@ export default function App() { crash in markdown rendering or a parameter widget doesn't take the whole pair down — the user still has the other half to work with. */} - +
{!session ? ( @@ -425,7 +505,10 @@ export default function App() { keeps the layout legible. The canvas store survives across this swap, so the user's snippet is still there if they toggle params back. */} - + {canvasOpen ? ( @@ -455,6 +538,12 @@ export default function App() { + + + + + + @@ -506,8 +595,8 @@ export default function App() { )} {/* Global toast host. Mounted unconditionally so messages from any - surface (including the lock screen path, in the future) land in a - predictable spot. Renders nothing when no toasts are queued. */} + surface land in a predictable spot. Renders nothing when no toasts + are queued, and nothing while locked (it clears the stack on lock). */} diff --git a/src/components/ChatCanvas.tsx b/src/components/ChatCanvas.tsx index f64e9b0..7e7e96f 100644 --- a/src/components/ChatCanvas.tsx +++ b/src/components/ChatCanvas.tsx @@ -200,6 +200,7 @@ export function ChatCanvas() { }; const scrollerRef = useRef(null); + const contentRef = useRef(null); const stickToBottom = useRef(true); // Mirror of the ref into React state — the scroll-to-bottom button needs // to re-render when this flips, which a ref alone can't trigger. Kept in @@ -268,7 +269,12 @@ export function ChatCanvas() { }, [sessionId]); useEffect(() => { - const open = () => setSearchOpen(true); + // `/find ` passes its text as `detail.query` to prefill the finder. + const open = (e: Event) => { + const query = (e as CustomEvent<{ query?: string } | null>).detail?.query; + if (query) setSearchQuery(query); + setSearchOpen(true); + }; window.addEventListener("loach:open-chat-search", open); return () => window.removeEventListener("loach:open-chat-search", open); }, []); @@ -298,6 +304,9 @@ export function ChatCanvas() { if (!id) return; const el = messageRefs.current.get(id); if (el) { + // Off the bottom until the scroll lands, or the content observer below + // pulls the view back down when opening the finder mounts older rows. + stickToBottom.current = false; el.scrollIntoView({ behavior: prefersReducedMotion() ? "auto" : "smooth", block: "center", @@ -392,6 +401,9 @@ export function ChatCanvas() { }; const onSearchKeyDown = (e: React.KeyboardEvent) => { + // Enter / Esc confirming or cancelling an IME candidate belong to the + // IME, not the finder. keyCode 229 too — see ChatInput. + if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return; if (e.key === "Escape") { e.preventDefault(); closeSearch(); @@ -434,6 +446,21 @@ export function ChatCanvas() { if (el) el.scrollTop = el.scrollHeight; }, [messages, isStreaming, waitingHere]); + // Content also grows with no change to `messages`: KaTeX loads lazily + // after a chat with formulas first renders, and typesetting it pushed the + // tail below the fold of a view that had already scrolled to the bottom. + // Follow those too, while the user hasn't scrolled up. + useEffect(() => { + const el = scrollerRef.current; + const content = contentRef.current; + if (!el || !content) return; + const observer = new ResizeObserver(() => { + if (stickToBottom.current) el.scrollTop = el.scrollHeight; + }); + observer.observe(content); + return () => observer.disconnect(); + }, []); + // Reset the window when switching chats — the new transcript opens at its // own tail, not the depth the previous chat happened to be revealed to. // @@ -527,6 +554,9 @@ export function ChatCanvas() { setJumpTarget(null); const el = messageRefs.current.get(jumpTarget); if (!el) return; + // As for a search match: the rows this jump may have revealed would + // otherwise have the content observer pull the view back to the bottom. + stickToBottom.current = false; el.scrollIntoView({ behavior: prefersReducedMotion() ? "auto" : "smooth", block: "center", @@ -600,7 +630,7 @@ export function ChatCanvas() { on top of the pinned bar. */}
-
+
{hiddenBefore > 0 && (
- / + {/* Same chevron as the Space and dataset pages' breadcrumbs. */} + )} @@ -505,7 +534,10 @@ export function ChatHeader({ session }: { session: Session | undefined }) { {openaiKeySet ? "No models" : "Not connected"} )} - {openaiModels.slice(0, 30).map((m) => ( + {/* All of them: the menu scrolls, and typing a model's first + letters jumps to it. A cap at 30 left OpenRouter's and + OpenAI's later models with no way in. */} + {openaiModels.map((m) => ( select("openai", m.id)}> {m.label} @@ -582,11 +614,16 @@ export function ChatHeader({ session }: { session: Session | undefined }) { }} iconClassName="mr-2 h-4 w-4" /> + {session && } Rename - void handleFork()}> + void handleFork()} + disabled={streamingHere} + title={streamingHere ? "Wait for the reply to finish" : undefined} + > Fork this chat @@ -657,6 +694,8 @@ export function ChatHeader({ session }: { session: Session | undefined }) { value={renameDraft} onChange={(e) => setRenameDraft(e.target.value)} onKeyDown={(e) => { + // The Enter that confirms an IME candidate isn't the rename's. + if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return; if (e.key === "Enter") { e.preventDefault(); commitRename(); diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 2e646da..67ac139 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -5,6 +5,7 @@ import { ClipboardPaste, FileUp, FolderOpen, + Library, Mic, Plus, Scissors, @@ -29,16 +30,33 @@ import { fileToAttachment, FileTooLargeError, } from "@/lib/files"; -import { useChatStore } from "@/stores/chatStore"; +import { isPreparingSend, useChatStore } from "@/stores/chatStore"; +import { useModelsStore } from "@/stores/modelsStore"; import { usePrivateChatStore } from "@/stores/privateChatStore"; +import { useSnippetStore } from "@/stores/snippetStore"; +import { useSpaceStore } from "@/stores/spaceStore"; import { useUIStore } from "@/stores/uiStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useToastStore } from "@/stores/toastStore"; import { cn } from "@/lib/utils"; import { logger } from "@/lib/logger"; +import { formatShortcut, modalDialogOpen } from "@/lib/shortcuts"; import { openSessionWorkspace } from "@/lib/tauri"; +import { chatRetrievalMode } from "@/lib/sources"; import { ChipDivider, PersonaChip, ToneChip } from "./ComposerChip"; import { WorkspaceNotice } from "./WorkspaceNotice"; +import { LibraryPicker, type LibrarySelection } from "./library/LibraryPicker"; +import { + ChatSourceChips, + chatNumCtx, + matchMentions, + MentionPalette, + mentionAt, + useChatSources, + useNewChatModelCtx, + type MentionEntry, +} from "./library/ChatSources"; +import { useLibraryStore } from "@/stores/libraryStore"; import { DEFAULT_PERSONA_ID, getPersona, @@ -48,17 +66,32 @@ import { dispatch as dispatchCommand } from "@/lib/commands/dispatch"; import { isCommandInput, matchCommands, + modelArgValues, parseInput, + type ArgSources, type PaletteEntry, } from "@/lib/commands/parser"; import { orderByRecency, parseRecentCommands } from "@/lib/commands/recency"; import type { CommandResult } from "@/lib/commands/types"; -import type { Attachment } from "@/types"; +import { OPENAI_COMPAT_NUM_CTX, type Attachment } from "@/types"; interface ChatInputProps { centered?: boolean; } +/** Composer drafts by chat — `space:` for the composer on a Space's page + * — so text and files stay with the chat they were written in. The composer + * remounts for each chat (App keys the chat view by session), so a draft + * kept in component state would be lost on every switch — and before that + * keying, one composer's state followed the user, sending a file attached in + * one chat with the next chat's message. Kept out here, in memory only. */ +const drafts = new Map(); + +/** The `composerInsertSeq` last applied. One composer is mounted at a time, + * so a newer one at mount is a primer (a snippet's Run, a failed send put + * back) that landed while none was. */ +let appliedInsertSeq = 0; + export function ChatInput({ centered = false }: ChatInputProps) { // "Streaming in this chat" means the global runner is currently producing // tokens for the active session. "Waiting in this chat" means this chat @@ -67,20 +100,26 @@ export function ChatInput({ centered = false }: ChatInputProps) { // chat, period. The streaming case additionally morphs the send button // into a stop button (see "Send/Stop morph" below). const activeSessionId = useChatStore((s) => s.activeSessionId); + const viewingSpaceId = useSpaceStore((s) => s.viewingSpaceId); + // From a Space's page the composer always starts a new chat in that Space + // (see `sendUserMessage`), so the chat left open is neither its target nor + // what its send button may stop or wait on. + const composerSessionId = viewingSpaceId ? null : activeSessionId; + const draftKey = viewingSpaceId ? `space:${viewingSpaceId}` : (activeSessionId ?? ""); const streamingSessionId = useChatStore((s) => s.streamingSessionId); const waitingHere = useChatStore((s) => - !!activeSessionId && - s.queue.some((t) => t.sessionId === activeSessionId), + !!composerSessionId && + s.queue.some((t) => t.sessionId === composerSessionId), ); const streamingThisChat = - !!activeSessionId && streamingSessionId === activeSessionId; + !!composerSessionId && streamingSessionId === composerSessionId; const send = useChatStore((s) => s.sendUserMessage); const cancelForSession = useChatStore((s) => s.cancelForSession); // The chat's workspace directory, if it has one. Read off the session // rather than kept locally so it survives a remount and stays in step // with the row the backend reads when the next turn starts. const workspaceRoot = useChatStore( - (s) => s.sessions.find((x) => x.id === s.activeSessionId)?.workspace_root ?? null, + (s) => s.sessions.find((x) => x.id === composerSessionId)?.workspace_root ?? null, ); const pickWorkspace = useChatStore((s) => s.pickWorkspace); const clearWorkspace = useChatStore((s) => s.clearWorkspace); @@ -88,7 +127,7 @@ export function ChatInput({ centered = false }: ChatInputProps) { // master switch in Settings → Tools has been turned off since. const workspaceToolsEnabled = useSettingsStore((s) => s.workspace_tool_enabled); const workspaceInstructions = useChatStore((s) => - s.activeSessionId ? (s.workspaceInstructions[s.activeSessionId] ?? null) : null, + composerSessionId ? (s.workspaceInstructions[composerSessionId] ?? null) : null, ); const refreshWorkspaceInstructions = useChatStore( (s) => s.refreshWorkspaceInstructions, @@ -99,13 +138,12 @@ export function ChatInput({ centered = false }: ChatInputProps) { // The backend reads its own copy every turn regardless; this only feeds // the workspace notice and the context usage row. useEffect(() => { - if (!activeSessionId || !workspaceRoot || streamingThisChat) return; - void refreshWorkspaceInstructions(activeSessionId); - }, [activeSessionId, workspaceRoot, streamingThisChat, refreshWorkspaceInstructions]); + if (!composerSessionId || !workspaceRoot || streamingThisChat) return; + void refreshWorkspaceInstructions(composerSessionId); + }, [composerSessionId, workspaceRoot, streamingThisChat, refreshWorkspaceInstructions]); const composerDraft = useUIStore((s) => s.composerDraft); const composerAttachments = useUIStore((s) => s.composerAttachments); const composerInsertSeq = useUIStore((s) => s.composerInsertSeq); - const setComposerDraft = useUIStore((s) => s.setComposerDraft); const personaIdBySession = useUIStore((s) => s.personaIdBySession); const pendingPersonaId = useUIStore((s) => s.pendingPersonaId); const setSessionPersona = useUIStore((s) => s.setSessionPersona); @@ -124,8 +162,8 @@ export function ChatInput({ centered = false }: ChatInputProps) { // an open chat reads from `personaIdBySession`; the welcome screen (no // session yet) falls back to `pendingPersonaId`, which chatStore.newSession // consumes when it materialises the session. - const activePersonaId = activeSessionId - ? personaIdBySession[activeSessionId] + const activePersonaId = composerSessionId + ? personaIdBySession[composerSessionId] : pendingPersonaId; // The chip only renders for a *real* persona — "default" (None) is the // absence of a persona, not a thing to advertise above the composer. @@ -140,18 +178,57 @@ export function ChatInput({ centered = false }: ChatInputProps) { // says which scope it came from. Scoped to a real session: the welcome // composer has no chat to override yet (persona parks in `pendingPersonaId`; // tone has no such slot), so the chip appears from the first send onward. - const sessionToneId = activeSessionId - ? toneIdBySession[activeSessionId] + const sessionToneId = composerSessionId + ? toneIdBySession[composerSessionId] : undefined; const effectiveToneId = sessionToneId ?? defaultToneId ?? DEFAULT_TONE_ID; const activeTone = - activeSessionId && effectiveToneId !== DEFAULT_TONE_ID + composerSessionId && effectiveToneId !== DEFAULT_TONE_ID ? getTone(effectiveToneId) : null; const toneFromGlobal = !sessionToneId; - const [text, setText] = useState(composerDraft); - const [attachments, setAttachments] = useState([]); + const [text, setText] = useState(() => drafts.get(draftKey)?.text ?? ""); + const [attachments, setAttachments] = useState( + () => drafts.get(draftKey)?.attachments ?? [], + ); + /** A slash command's text while it runs. It isn't kept as the draft: the + * command has it, even when it moves the composer to another chat (`/new`, + * `/space`) or remounts it (`/clear` empties the chat). State, so the + * draft is written without it before either can happen. */ + const [runningCommand, setRunningCommand] = useState(null); + // Latest text, for what outlives the render it started in: a command still + // running, a dictation result. + const textRef = useRef(text); + textRef.current = text; + /** Whose draft is in the box. */ + const draftKeyRef = useRef(draftKey); + const mountedRef = useRef(false); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + // File the draft as it changes, not when the composer unmounts: one that + // mounts in the same commit — an emptied chat's hero, a first send's chat + // view — reads it before this one's cleanup would run. Ahead of the swap + // below, so a change landing with a switch is filed under its own chat. + useEffect(() => { + drafts.set(draftKeyRef.current, { + text: text === runningCommand ? "" : text, + attachments, + }); + }, [text, attachments, runningCommand]); + // Swap drafts when the composer moves to another chat. Declared before the + // primer effect below so a primer for the chat just opened wins. + useEffect(() => { + if (draftKeyRef.current === draftKey) return; + draftKeyRef.current = draftKey; + const saved = drafts.get(draftKey); + setText(saved?.text ?? ""); + setAttachments(saved?.attachments ?? []); + }, [draftKey]); const [error, setError] = useState(null); const [dragging, setDragging] = useState(false); // Guards the menu item while the native dialog is up. The dialog is modal @@ -169,6 +246,60 @@ export function ChatInput({ centered = false }: ChatInputProps) { const [paletteIndex, setPaletteIndex] = useState(0); const [paletteDismissed, setPaletteDismissed] = useState(false); const [commandResult, setCommandResult] = useState(null); + // Library: the "+ → From Library" picker, and the `#` mention list. The + // caret is tracked so the mention follows the token being typed, not the + // end of the text. + const [libraryPickerOpen, setLibraryPickerOpen] = useState(false); + const [caret, setCaret] = useState(0); + const [mentionIndex, setMentionIndex] = useState(0); + // Enter only picks a mention once the user has moved through the list + // with the arrows; until then it sends, so a message ending in `#tag` + // isn't swallowed by a Library match. Tab always picks. + const [mentionNavigated, setMentionNavigated] = useState(false); + const [mentionDismissedAt, setMentionDismissedAt] = useState(null); + const activeSession = useChatStore((s) => + s.activeSessionId ? s.sessions.find((x) => x.id === s.activeSessionId) : undefined, + ); + // Library sources picked on a Space's page wait for the new chat + // (`composerSessionId` is null there) instead of landing on whichever chat + // happened to be open before. + const sourcesSpaceId = viewingSpaceId ?? activeSession?.space_id ?? null; + const sourcesSpace = useSpaceStore((s) => + sourcesSpaceId ? s.spaces.find((x) => x.id === sourcesSpaceId) : undefined, + ); + const chatSources = useChatSources(composerSessionId, sourcesSpaceId); + const hasChatSources = chatSources.chips.length > 0 || chatSources.spaceCount > 0; + // A Modelfile's own window, which the send layers under the chat's (for + // an OpenAI-compatible chat, the window Loach assumes for it). Before the + // chat exists, the window of the model it will start on. + const sessionModelCtx = useModelsStore((s) => + !composerSessionId || !activeSession + ? undefined + : activeSession.provider === "ollama" + ? activeSession.model + ? s.modelDefaults[activeSession.model]?.num_ctx + : undefined + : OPENAI_COMPAT_NUM_CTX, + ); + const newChatModelCtx = useNewChatModelCtx(sourcesSpace); + const composerModelCtx = composerSessionId && activeSession ? sessionModelCtx : newChatModelCtx; + const libraryItems = useLibraryStore((s) => s.items); + const libraryDatasets = useLibraryStore((s) => s.datasets); + const addLibrarySources = useLibraryStore((s) => s.addSources); + const addPendingSources = useLibraryStore((s) => s.addPending); + // Names the palette completes after `/model `, `/space ` and `/snippet `. + // They live in stores, which the commands layer doesn't import. + const models = useModelsStore((s) => s.models); + const spaces = useSpaceStore((s) => s.spaces); + const snippets = useSnippetStore((s) => s.snippets); + const argSources: ArgSources = useMemo( + () => ({ + model: modelArgValues(models), + space: spaces.map((s) => s.name), + snippet: snippets.map((s) => s.title), + }), + [models, spaces, snippets], + ); // Recency is applied HERE rather than inside the palette: `paletteIndex` // indexes into this array, and both the keyboard handler and the palette // must agree on the order or Enter would accept a different row than the @@ -177,11 +308,11 @@ export function ChatInput({ centered = false }: ChatInputProps) { () => isCommandInput(text) ? orderByRecency( - matchCommands(text.slice(1)), + matchCommands(text.slice(1), argSources), parseRecentCommands(recentCommands), ) : [], - [text, recentCommands], + [text, recentCommands, argSources], ); const fileInputRef = useRef(null); const textareaRef = useRef(null); @@ -190,6 +321,53 @@ export function ChatInput({ centered = false }: ChatInputProps) { const paletteOpen = !paletteDismissed && isCommandInput(text) && paletteEntries.length > 0; + const mention = paletteOpen ? null : mentionAt(text, caret); + // Keyed on the query string, not the `mention` object, which is new on + // every keystroke. + const mentionQuery = mention?.query; + const mentionEntries: MentionEntry[] = useMemo(() => { + if (mentionQuery === undefined) return []; + const linked = new Set(chatSources.chips.map((c) => `${c.kind}:${c.id}`)); + return matchMentions(mentionQuery, libraryItems, libraryDatasets, 12) + .filter((e) => !linked.has(`${e.kind}:${e.id}`)) + .slice(0, 8); + }, [mentionQuery, libraryItems, libraryDatasets, chatSources.chips]); + const mentionOpen = + !!mention && mentionEntries.length > 0 && mentionDismissedAt !== mention.start; + + useEffect(() => { + setMentionIndex(0); + setMentionNavigated(false); + }, [mention?.query, mention?.start]); + + /** Link Library items / datasets to this chat — or park them until the + * chat exists, when sending from the welcome screen. */ + const attachFromLibrary = async (sel: LibrarySelection) => { + if (composerSessionId) await addLibrarySources("session", composerSessionId, sel); + else addPendingSources(sel, sourcesSpaceId); + }; + + const acceptMention = (entry: MentionEntry) => { + if (!mention) return; + // Drop the `#query` token itself; the chip above says what was added. + const before = text.slice(0, mention.start); + const after = text.slice(caret).replace(/^ /, ""); + const next = before + after; + setText(next); + void attachFromLibrary( + entry.kind === "dataset" + ? { itemIds: [], datasetIds: [entry.id] } + : { itemIds: [entry.id], datasetIds: [] }, + ).catch((e) => setError(e instanceof Error ? e.message : String(e))); + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(before.length, before.length); + setCaret(before.length); + }); + }; + // Reset the Esc-dismissed flag when the user clears the textarea or // removes the leading slash — otherwise the palette would stay hidden // forever once the user has dismissed it once per session. @@ -197,17 +375,29 @@ export function ChatInput({ centered = false }: ChatInputProps) { if (!isCommandInput(text) && paletteDismissed) setPaletteDismissed(false); }, [text, paletteDismissed]); - // Clamp the highlight when the entry list shrinks (e.g. the user typed - // another character and only one suggestion remains). + // Back to the top row whenever the text changes, like the mention list: a + // highlight left on row 2 of `/model ll` was still on row 2 once `llama3` + // was typed out — `llama3:70b` — and Enter swapped that in. useEffect(() => { - if (paletteIndex >= paletteEntries.length) { - setPaletteIndex(Math.max(0, paletteEntries.length - 1)); - } - }, [paletteEntries.length, paletteIndex]); + setPaletteIndex(0); + }, [text]); + + // Clamp the highlight when the entry list shrinks under unchanged text + // (the model list refreshing, say). An updater, not the rendered value: + // when typing shrinks the list, the reset above runs in the same commit, + // and clamping the stale index here overrode it. + useEffect(() => { + const last = Math.max(0, paletteEntries.length - 1); + setPaletteIndex((i) => Math.min(i, last)); + }, [paletteEntries.length]); const acceptPaletteEntry = (entry: PaletteEntry) => { setText(entry.insertText); - setComposerDraft(entry.insertText); + // Back to the top row, where the exact match for the accepted text + // lands. A highlight left further down could point at a longer name + // that also contains it (`llama3` → `llama3:70b`), and Enter would + // swap that in instead of running the command. + setPaletteIndex(0); requestAnimationFrame(() => { const el = textareaRef.current; if (!el) return; @@ -220,12 +410,21 @@ export function ChatInput({ centered = false }: ChatInputProps) { // External insert (e.g. from suggestion chips or a Snippet "Run") bumps // the seq counter. Text always reseeds; attachments reseed only when the // primer supplied some (so plain suggestion-chip inserts don't wipe the - // user's pending file picks). + // user's pending file picks). On mount only a primer not yet applied + // counts. `newSession`'s reset (no text, no files) is passed over: the + // composer moves on to the new chat's own empty draft, and applying it + // here wiped the draft of whichever chat it was still showing. useEffect(() => { - setText(composerDraft); - if (composerAttachments.length > 0) { - setAttachments(composerAttachments); + if ( + composerInsertSeq !== appliedInsertSeq && + (composerDraft || composerAttachments.length > 0) + ) { + setText(composerDraft); + if (composerAttachments.length > 0) { + setAttachments(composerAttachments); + } } + appliedInsertSeq = composerInsertSeq; const el = textareaRef.current; if (el) { requestAnimationFrame(() => { @@ -288,15 +487,16 @@ export function ChatInput({ centered = false }: ChatInputProps) { // Private Chat covers this composer without unmounting it, and these // listeners are on the window — so a file dropped onto the overlay was // attached here, to the regular chat behind it, and saved (and read by - // the memory extractor) with that chat's next send. Refuse drops while - // it's open; the events stay cancelled, as before, so they never fall - // through to the webview's own file handling. - const privateChatOpen = () => usePrivateChatStore.getState().open; + // the memory extractor) with that chat's next send. The same went for a + // modal dialog (Settings, the snippet editor, Import) open over it. + // Refuse drops while either is; the events stay cancelled, as before, so + // they never fall through to the webview's own file handling. + const covered = () => usePrivateChatStore.getState().open || modalDialogOpen(); const onDragEnter = (e: DragEvent) => { if (!hasFiles(e)) return; e.preventDefault(); - if (privateChatOpen()) return; + if (covered()) return; depth += 1; if (depth === 1) setDragging(true); }; @@ -304,10 +504,10 @@ export function ChatInput({ centered = false }: ChatInputProps) { if (!hasFiles(e)) return; // Must preventDefault on dragover for the subsequent `drop` event to // fire. Setting the dropEffect here also changes the OS cursor to the - // "copy" affordance (or "not allowed" under Private Chat). + // "copy" affordance (or "not allowed" under Private Chat or a dialog). e.preventDefault(); if (e.dataTransfer) { - e.dataTransfer.dropEffect = privateChatOpen() ? "none" : "copy"; + e.dataTransfer.dropEffect = covered() ? "none" : "copy"; } }; const onDragLeave = (e: DragEvent) => { @@ -320,7 +520,7 @@ export function ChatInput({ centered = false }: ChatInputProps) { e.preventDefault(); depth = 0; setDragging(false); - if (privateChatOpen()) return; + if (covered()) return; const files = e.dataTransfer?.files; if (files && files.length) await ingest(Array.from(files)); }; @@ -378,8 +578,8 @@ export function ChatInput({ centered = false }: ChatInputProps) { // `pendingPersonaId` until newSession adopts it. const applyPersona = (personaId: string) => { if (!getPersona(personaId)) return; - if (activeSessionId) { - setSessionPersona(activeSessionId, personaId); + if (composerSessionId) { + setSessionPersona(composerSessionId, personaId); } else { setPendingPersona(personaId === DEFAULT_PERSONA_ID ? null : personaId); } @@ -392,15 +592,15 @@ export function ChatInput({ centered = false }: ChatInputProps) { // global `default_tone_id` and the chip would reappear. The user's global // setting is never touched from here. const clearTone = () => { - if (activeSessionId) setSessionTone(activeSessionId, DEFAULT_TONE_ID); + if (composerSessionId) setSessionTone(composerSessionId, DEFAULT_TONE_ID); }; const chooseWorkspace = async () => { - if (!activeSessionId) return; + if (!composerSessionId) return; setError(null); setPickingWorkspace(true); try { - await pickWorkspace(activeSessionId); + await pickWorkspace(composerSessionId); } catch (e) { // Surfaced in the composer's own error line rather than a toast: the // user is looking here, having just used the menu right beside it. @@ -414,10 +614,10 @@ export function ChatInput({ centered = false }: ChatInputProps) { }; const openWorkspace = async () => { - if (!activeSessionId) return; + if (!composerSessionId) return; setError(null); try { - await openSessionWorkspace(activeSessionId); + await openSessionWorkspace(composerSessionId); } catch (e) { logger.error("workspace open failed", e); setError( @@ -427,10 +627,10 @@ export function ChatInput({ centered = false }: ChatInputProps) { }; const removeWorkspace = async () => { - if (!activeSessionId) return; + if (!composerSessionId) return; setError(null); try { - await clearWorkspace(activeSessionId); + await clearWorkspace(composerSessionId); } catch (e) { logger.error("workspace remove failed", e); setError( @@ -480,6 +680,18 @@ export function ChatInput({ centered = false }: ChatInputProps) { picked = fileItems; } + // Office apps put a picture of the selection beside its text: cells + // copied from Excel or a paragraph from Word arrived as a PNG, and the + // text was dropped. Text wins over images-only. Plain text, not HTML: a + // browser's "Copy image" carries just an `` tag in text/html, and + // pasting that into the textarea would insert nothing. + if ( + dt.getData("text/plain").trim() && + picked.every((f) => f.type.startsWith("image/")) + ) { + return; + } + if (picked.length > 0) { e.preventDefault(); await ingest(picked); @@ -511,7 +723,6 @@ export function ChatInput({ centered = false }: ChatInputProps) { const end = sel?.end ?? el?.selectionEnd ?? text.length; const next = text.slice(0, start) + clip + text.slice(end); setText(next); - setComposerDraft(next); requestAnimationFrame(() => { if (!el) return; el.focus(); @@ -531,7 +742,6 @@ export function ChatInput({ centered = false }: ChatInputProps) { } const next = text.slice(0, sel.start) + text.slice(sel.end); setText(next); - setComposerDraft(next); requestAnimationFrame(() => { const el = textareaRef.current; if (!el) return; @@ -581,9 +791,12 @@ export function ChatInput({ centered = false }: ChatInputProps) { // Anchor the dictation against the text that was already in the box when // the user pressed the mic. Each interim result is rendered as // `baseText + interim`; each final result is committed back into - // `baseText`. This way the user can keep typing between phrases without - // having their typing clobbered by a mid-sentence interim update. + // `baseText`. `dictatedRef` is what the last result wrote: when the box + // holds anything else by the next one, the user edited it meanwhile, and + // that becomes the new base — writing `baseText + interim` regardless + // erased whatever they typed while dictating. const baseTextRef = useRef(""); + const dictatedRef = useRef(""); // Tear down recognition on unmount so we don't leave the mic hot. useEffect( @@ -612,11 +825,15 @@ export function ChatInput({ centered = false }: ChatInputProps) { recognition.interimResults = true; recognition.maxAlternatives = 1; baseTextRef.current = text; + dictatedRef.current = text; const joinWithSpace = (a: string, b: string) => a.length === 0 || /\s$/.test(a) || b.length === 0 ? a + b : `${a} ${b}`; recognition.onresult = (ev) => { + if (textRef.current !== dictatedRef.current) { + baseTextRef.current = textRef.current; + } let interim = ""; let appendedFinal = ""; for (let i = ev.resultIndex; i < ev.results.length; i++) { @@ -634,8 +851,11 @@ export function ChatInput({ centered = false }: ChatInputProps) { const next = interim ? joinWithSpace(baseTextRef.current, interim) : baseTextRef.current; + dictatedRef.current = next; + // Ahead of the render, so a result landing before it isn't taken for + // an edit. + textRef.current = next; setText(next); - setComposerDraft(next); }; recognition.onerror = (ev) => { @@ -692,7 +912,11 @@ export function ChatInput({ centered = false }: ChatInputProps) { // fall through to the normal send path so the user's literal text still // reaches the model (the "ignore unknown" rule). if (trimmed && isCommandInput(trimmed)) { + const insertSeq = useUIStore.getState().composerInsertSeq; + const key = draftKey; + setRunningCommand(text); const outcome = await dispatchCommand(trimmed, { confirm }); + setRunningCommand(null); if (outcome.kind === "handled") { // "Handled" covers the failed-command case too, and that's the // behaviour we want: reaching for a command is the signal recency @@ -700,11 +924,23 @@ export function ChatInput({ centered = false }: ChatInputProps) { // retry. `passthrough` (unregistered text) is correctly excluded. const name = parseInput(trimmed)?.name; if (name) recordCommandUse(name); - setText(""); - setComposerDraft(""); + const result = outcome.result; + // Clear the command once it's done, unless: + // - it failed: its text stays, to be fixed and run again; + // - it filled the composer itself (`/snippet` primes it with the + // snippet's text): clearing too wiped that before it ever showed; + // - the box holds something else by now: anything typed while a slow + // one ran (`/fetch` can take 30 s) is the user's, not the command's. + if ( + !(result.kind === "toast" && result.tone === "error") && + useUIStore.getState().composerInsertSeq === insertSeq && + draftKeyRef.current === key && + textRef.current === text + ) { + setText(""); + } setError(null); setPaletteDismissed(false); - const result = outcome.result; if (result.kind === "toast") { useToastStore.getState().push({ kind: result.tone === "error" ? "error" : "info", @@ -730,20 +966,29 @@ export function ChatInput({ centered = false }: ChatInputProps) { // will actually observe (the hook-bound selectors above could be a // render behind). const state = useChatStore.getState(); - const activeId = state.activeSessionId; + // A Space's page sends to a chat it's about to create — nothing is busy. + const activeId = useSpaceStore.getState().viewingSpaceId ? null : state.activeSessionId; const busyHere = !!activeId && (state.streamingSessionId === activeId || - state.queue.some((t) => t.sessionId === activeId)); - - // One-in-flight-per-chat cap: if this chat is already running or - // already has a waiter, swallow the submit. The send button is disabled - // in this state too — this is belt-and-suspenders. + state.queue.some((t) => t.sessionId === activeId) || + isPreparingSend(activeId)); + + // One-in-flight-per-chat cap: if this chat is already running, already + // has a waiter, or is still preparing a send (resolving its sources can + // take seconds), swallow the submit with the draft kept — the store + // would drop it anyway. The send button is disabled while running or + // queued too — this is belt-and-suspenders. if (busyHere) return; // Stop dictation before sending so the recogniser doesn't keep writing - // into the now-empty textarea. - if (listening) stopDictation(); + // into the now-empty textarea — and drop what it still delivers: `stop()` + // hands over a last final result, which refilled the box with the + // message just sent. + if (listening) { + if (recognitionRef.current) recognitionRef.current.onresult = null; + stopDictation(); + } // Optimistic clear so the composer feels snappy. We snapshot what we're // sending so the catch branch can put it back — losing the user's draft @@ -751,8 +996,11 @@ export function ChatInput({ centered = false }: ChatInputProps) { const sentText = trimmed; const sentAttachments = attachments; setText(""); - setComposerDraft(""); setAttachments([]); + // Filed here, not by the effect: a send from a Space's page unmounts this + // composer in this same render, before that could run, and the draft left + // behind for the page mustn't be this message. + drafts.set(draftKey, { text: "", attachments: [] }); setError(null); try { @@ -762,15 +1010,25 @@ export function ChatInput({ centered = false }: ChatInputProps) { // canvas tells the user that happened. await send(sentText, sentAttachments); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to send message"); + const message = e instanceof Error ? e.message : "Failed to send message"; + if (!mountedRef.current) { + // Sent from a Space's page: `sendUserMessage` left the page, and so + // this composer, before failing — its error line and state are gone. + // Prime the composer on screen now (the new chat's) and say why. + useUIStore.getState().primeComposer(sentText, sentAttachments); + useToastStore.getState().push({ + kind: "error", + title: "Couldn't send message", + body: message, + }); + return; + } + setError(message); // Restore the draft only if the user hasn't started a new one in the // meantime — failures are typically synchronous ("no model selected") // so this branch usually wins, but we don't want to clobber fresh // typing if the send took long enough to lose to. setText((curr) => (curr === "" ? sentText : curr)); - if (useUIStore.getState().composerDraft === "") { - setComposerDraft(sentText); - } setAttachments((curr) => (curr.length === 0 ? sentAttachments : curr)); } }; @@ -788,8 +1046,8 @@ export function ChatInput({ centered = false }: ChatInputProps) { // has a pending prompt, and the canvas's waiting banner exposes a // dedicated Cancel for the queued task. const onPrimaryClick = () => { - if (streamingThisChat && activeSessionId) { - void cancelForSession(activeSessionId); + if (streamingThisChat && composerSessionId) { + void cancelForSession(composerSessionId); return; } void submit(); @@ -833,7 +1091,7 @@ export function ChatInput({ centered = false }: ChatInputProps) { onDismiss={() => setCommandResult(null)} /> )} - {(attachments.length > 0 || activePersona || activeTone) && ( + {(attachments.length > 0 || activePersona || activeTone || hasChatSources) && ( // Config chips first, then payload. The divider marks the lifetime // boundary — everything left of it survives the send, everything // right of it goes with the message. @@ -848,7 +1106,8 @@ export function ChatInput({ centered = false }: ChatInputProps) { onRemove={clearTone} /> )} - {(activePersona || activeTone) && + + {(activePersona || activeTone || hasChatSources) && attachments.length > 0 && } {attachments.map((a, i) => ( )} + {mentionOpen && ( + + )} {paletteOpen && ( @@ -931,6 +1198,15 @@ export function ChatInput({ centered = false }: ChatInputProps) {

+ setLibraryPickerOpen(true)}> + +
+
From Library
+

+ Use saved sources in this chat · or type # +

+
+
{/* Changing the folder waits for a running reply to end: that turn keeps the folder it started with (see WorkspaceNotice's `locked`). Adding a first one mid-turn is harmless — it @@ -938,14 +1214,14 @@ export function ChatInput({ centered = false }: ChatInputProps) { void chooseWorkspace()} disabled={ - !activeSessionId || pickingWorkspace || (streamingThisChat && !!workspaceRoot) + !composerSessionId || pickingWorkspace || (streamingThisChat && !!workspaceRoot) } >
{workspaceRoot ? "Change directory" : "Add directory"}

- {!activeSessionId + {!composerSessionId ? "Send a message first to start this chat" : streamingThisChat && workspaceRoot ? "Wait for the reply to finish" @@ -974,16 +1250,40 @@ export function ChatInput({ centered = false }: ChatInputProps) { aria-label="Message" value={text} onChange={(e) => { - // Mirror every keystroke into the persisted draft. ChatInput is - // mounted at several sites (hero composer, normal composer, - // SpaceView) and switching sessions / entering a Space remounts - // it; without this the in-progress draft re-seeds from a stale - // composerDraft on remount and is silently lost. setText(e.target.value); - setComposerDraft(e.target.value); + setCaret(e.target.selectionStart ?? e.target.value.length); }} + onSelect={(e) => setCaret(e.currentTarget.selectionStart ?? 0)} onKeyDown={(e) => { - if (e.nativeEvent.isComposing) return; + // keyCode 229 too: WebKit (macOS, Linux) fires the Enter that + // confirms an IME candidate after `compositionend`, so + // `isComposing` is already false and the message would send. + if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return; + if (mentionOpen && mention) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setMentionIndex((i) => (i + 1) % mentionEntries.length); + setMentionNavigated(true); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setMentionIndex((i) => (i - 1 + mentionEntries.length) % mentionEntries.length); + setMentionNavigated(true); + return; + } + if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey && mentionNavigated)) { + e.preventDefault(); + const entry = mentionEntries[mentionIndex]; + if (entry) acceptMention(entry); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setMentionDismissedAt(mention.start); + return; + } + } // Palette navigation. Order matters: Tab/Enter accept the // highlighted entry into the textarea (no message send); // Enter without an open palette still sends, so unknown @@ -1106,6 +1406,27 @@ export function ChatInput({ centered = false }: ChatInputProps) { )} {!centered && }

+ c.kind === "item").map((c) => c.id)} + linkedDatasetIds={chatSources.chips.filter((c) => c.kind === "dataset").map((c) => c.id)} + numCtx={chatNumCtx( + composerSessionId ? activeSession?.params_json : null, + sourcesSpace?.default_params_json, + composerModelCtx, + )} + spaceId={sourcesSpaceId} + mode={ + composerSessionId && activeSession + ? chatRetrievalMode(activeSession, sourcesSpace?.retrieval_mode) + : (sourcesSpace?.retrieval_mode ?? "auto") + } + onConfirm={attachFromLibrary} + /> {ctxMenu && ( } label="Cut" - shortcut="Ctrl+X" + shortcut={formatShortcut({ mod: true, keys: ["x"] })} disabled={!hasSelection} onSelect={onCut} /> } label="Copy" - shortcut="Ctrl+C" + shortcut={formatShortcut({ mod: true, keys: ["c"] })} disabled={!hasSelection} onSelect={onCopy} /> } label="Paste" - shortcut="Ctrl+V" + shortcut={formatShortcut({ mod: true, keys: ["v"] })} onSelect={onPaste} />
} label="Select All" - shortcut="Ctrl+A" + shortcut={formatShortcut({ mod: true, keys: ["a"] })} disabled={!hasText} onSelect={onSelectAll} /> diff --git a/src/components/ChatSpaceMenu.tsx b/src/components/ChatSpaceMenu.tsx new file mode 100644 index 0000000..7d1d27d --- /dev/null +++ b/src/components/ChatSpaceMenu.tsx @@ -0,0 +1,113 @@ +import { Check, Layers } from "lucide-react"; +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "@/components/ui/dropdown-menu"; +import { useChatStore } from "@/stores/chatStore"; +import { useSpaceStore } from "@/stores/spaceStore"; +import { useToastStore } from "@/stores/toastStore"; +import type { Session } from "@/types"; + +/** "