diff --git a/.env.example b/.env.example index ff95577..0e8a654 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,25 @@ DB_SESSION_TTL_HOURS=168 # "1" or "true" = add the Secure flag to the session cookie. # Enable when serving over HTTPS (Coolify TLS proxy). DB_SECURE_COOKIE=false + +# ── API-key storage (optional encryption at rest) ──────────────────────────── +# Empty (default): keys.json stays PLAINTEXT (mode 0600) — backward compatible. +# Set: keys.json is AES-256-GCM encrypted; the key is derived from this +# passphrase via Argon2id. Existing plaintext files migrate on first access. +# +# This passphrase protects the file AT REST only — the real API key still +# flows to the provider's Authorization header per request. Treat it like a +# password: keep it in your secret manager, set it from FIRST BOOT, and never +# lose it (an encrypted keys.json without the passphrase is unreadable, and +# writes are refused so it is never silently overwritten). Generate with: +# openssl rand -hex 32 +DB_KEYS_PASSPHRASE= + +# ── Custom OpenAI-compatible provider (optional env override) ───────────────── +# Set these to provision/override the "openai-compatible" custom provider from +# the environment. When DB_OPENAI_COMPAT_BASE_URL is set, the UI shows the custom +# endpoint as READ-ONLY ("from env" badge) and the backend uses these values. +# Safe backward compat: unset → keys.json / in-app Settings behavior as before. +# DB_OPENAI_COMPAT_BASE_URL=https://proxy.example.com/v1 +# DB_OPENAI_COMPAT_API_KEY=sk-... +# DB_OPENAI_COMPAT_MODEL=gpt-oss-20b diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ab1537..8fe2e6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,9 @@ env: CARGO_TERM_COLORS: always NODE_VERSION: '22' +permissions: + contents: read + jobs: lint: runs-on: macos-14 @@ -27,6 +30,8 @@ jobs: run: npx oxlint frontend/ - name: TypeScript type check run: npx tsc -b + - name: ACL guard (every Tauri command has an allow entry) + run: node test/check-acl.mjs - name: Frontend tests run: npx vitest run - name: Check version consistency (manifests + locks + changelog section) @@ -56,6 +61,53 @@ jobs: run: cargo test --manifest-path server/Cargo.toml - name: Cargo clippy run: cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings + e2e: + runs-on: macos-14 + needs: [test] + # Heavy matrix job: PRs wait for the same approval gate as the build job; + # push to master / tags / manual run without approval (behavior unchanged). + environment: + name: ${{ github.event_name == 'pull_request' && 'build-approval' || 'release-auto' }} + strategy: + matrix: + browser: [chromium, webkit] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci + - name: Install Playwright browsers + run: npx playwright install chromium webkit + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Build web server (debug — used by the e2e harness) + run: cargo build --manifest-path server/Cargo.toml + - name: Build frontend + run: npm run build + - name: "E2E — all suites (web-smoke, trash, theme, AI transport)" + run: npm run test:e2e + env: + BROWSER: ${{ matrix.browser }} + - name: Upload e2e logs (server + browser console) + if: always() + uses: actions/upload-artifact@v7 + with: + name: e2e-logs-${{ matrix.browser }} + path: test/artifacts/ + if-no-files-found: ignore + + test-server-linux: + # Server-only on Linux: exercises cfg(target_os="linux") paths (delete → + # .trash server-side trash) that never run on the macos-14 test job. + runs-on: ubuntu-22.04 + needs: [lint] + steps: + - uses: actions/checkout@v7 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Cargo test (web server, Linux) + run: cargo test --manifest-path server/Cargo.toml + security: runs-on: ubuntu-22.04 needs: [lint] diff --git a/.gitignore b/.gitignore index 71f5def..100b3be 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,4 @@ src-tauri/target/ .env .env.* !.env.example -frontend/e2e/screenshot/ +test/artifacts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 19bc448..d6b502e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## v0.1.0-rc.1 — 2026-08-08 + +### Release candidate — AI transport hardening + web/Docker production readiness + +#### 🚀 Features +- **Vault Q&A / generation routing** — `[[wikilink]]`, questions, and generation verbs on an empty doc answer FROM vault context (no tools, plain Markdown insert); `isVaultGenerationIntent` + `buildVaultGroundingPrompt` +- **Custom OpenAI-compatible provider via env** — `DB_OPENAI_COMPAT_BASE_URL`/`_API_KEY`/`_MODEL` provision the custom endpoint headless; the UI shows it read-only ("from env" badge). Safe backward compat: unset = in-app behavior +- **Server-side trash (web)** — deleted files move to `.trash/` inside the vault (persistent in `/data`), excluded from tree/search/git; sidebar Trash panel with restore + empty +- **Clickable `[[wikilink]]`** — accent + underline visual, hover hint, single-click tooltip with Open action, Cmd/Ctrl+Click navigates (Obsidian-style); merged the wikilink search into the ⌘K link popover (one icon) +- **Persistent sessions** — `sessions.json` (SHA-256 hashed tokens) survives server restarts; no more forced re-login after redeploy +- **Optional keys.json encryption** — `DB_KEYS_PASSPHRASE` → AES-256-GCM at rest (Argon2id KDF); plaintext migrates on first access, encrypted files never overwritten without the passphrase +- **Consent-gated open access** — setup wizard "Skip" requires acknowledging that anyone with the URL can access + +#### 🐛 Bug Fixes +- **AI transport**: probe per provider+model (not per provider); ops-only output channel (no text+ops double-write); removed Path A→B retry (text-only models no longer pay 2× generation); `crypto.randomUUID` secure-context fallback (`uuid()`) +- **Web server**: `test_connection` camelCase args fix (was 400 + key never used); tool probe `tool_choice:"required"` + JSON `tools:false` contract +- **Shortcuts**: ⌘⇧F/⌘⇧P no longer hijack search; canonical ⌘⇧F/⌘⌥⇧F new file/folder + native ⌘N alias +- **Wiki**: recursive scan + content search for note-linking; `read_file` completes extension-less references to `.md` (never double-appends) + +#### 🧪 Testing & CI +- **E2E suite** — web-smoke, trash, theme-check, ai-debug (Path A + Path B) via one `npm run test:e2e`; CI matrix `[chromium, webkit]` with PR approval gate; logs (not screenshots) as artifacts +- **ACL guard** — CI fails if a Tauri command lacks its `allow-*` entry +- **test-server-linux** job — exercises `cfg(target_os="linux")` trash paths +- Rust tests 51+, frontend 64+, e2e 39+ assertions + ## v0.1.0-beta.4 — 2026-08-07 ### Docker /data self-heal + server boot diagnostics diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b201be7..b64de7c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,27 +77,46 @@ frontend/ PasswordInput.tsx Password field with show/hide toggle (login + change-password) AppearanceSettings.tsx Theme picker (named themes: Midnight / Bright Surfaces) ShortcutsModal.tsx Keyboard shortcuts reference + OnboardingGuide.tsx First-run guide for new vaults ErrorBoundary.tsx Root crash recovery screen stores/ editor.ts Tabs, file content, edited content, undo/redo state vault.ts Vault state, tree, folder expansion, recent vaults - aiSettings.ts Provider/model/saved-providers (persisted) — API keys live only in keychain + aiSettings.ts Provider/model/saved-providers (persisted) — API keys + live only backend-side (Keychain / keys.json, never the webview) auth.ts Web auth status (setup → login → ready), 401 handling gitStatus.ts Shared git status (branch + porcelain) — one poller theme.ts Named theme store (data-theme + Tauri window + meta theme-color) data/ - providers.ts Auto-generated provider/model catalog (models.dev) + providers.ts Generated provider/model catalog — regenerate with + `node frontend/data/fetch-providers.mjs` (models.dev api.json) + fetch-providers.mjs Catalog generator script (fetches models.dev, writes providers.ts) hooks/ useKeyboard.ts Keyboard shortcut handling usePolling.ts Interval polling useClickOutside.ts Click-outside detection for menus utils/ - aiBlocks.ts AI text → applyDocumentOperations (suggestions) + aiBlocks.ts Markdown → applyDocumentOperations; normalization, semantic + validation, prompt builders (edit/vault-first), vault-intent routing setupWizard.ts Pure setup-wizard validation + payload builder (unit-testable) - e2e/ - theme-check.mjs Playwright theme E2E (dark/light switch + picker) - web-smoke.mjs Playwright full-stack smoke (setup → login) - screenshot/ E2E screenshots (gitignored) + iteratorPolyfill.ts Safari ES2023 iterator polyfills + uuid.ts Secure-context-safe UUID v4 (crypto.randomUUID with + getRandomValues/Math.random fallback — plain-HTTP/IP access) +test/ + lib.mjs Shared CI-friendly harness: server + browser logs to + artifacts/, browser engine resolution (chromium/webkit, + system-Chrome fallback), pass/fail summary + run-all.mjs One entry point for all suites (npm run test:e2e; + BROWSER env picks the engine) + web-smoke.mjs Full-stack smoke: setup wizard → login → persistent + session across server restart + trash.mjs Trash UI: empty state (disabled) → restore → back in tree + theme-check.mjs Theme E2E (dark/light switch + picker in Settings) + ai-debug.mjs AI transport e2e (mock provider: Path A tools + Path B + text-only, selection + markdown) + check-acl.mjs ACL guard: every Tauri command has an allow-* entry + (run in CI lint) + artifacts/ Run logs (server + browser console) + results (gitignored) src-tauri/ Cargo.toml Desktop crate (bin docubook-desktop + lib docubook) tauri.conf.json Window config (theme: Dark), CSP, bundle @@ -117,14 +136,17 @@ server/ Web distribution — standalone axum crate (no Tauri) Cargo.toml Bin docubook-server (musl-friendly, [[bin]] path = main.rs) main.rs HTTP server: /api/ dispatcher, SSE AI streaming, auth middleware, static file serving (SPA fallback) - auth.rs Argon2id passwords, in-memory sessions, login rate limit + auth.rs Argon2id passwords, persistent sessions (sessions.json, + SHA-256 hashed tokens, survive restarts), login rate limit config.rs Config merge (env > /data/config.json > default) - keys.rs API-key store (keys.json, 0600) + keys.rs API-key store (keys.json, 0600; optional AES-256-GCM + encryption at rest via DB_KEYS_PASSPHRASE, Argon2id KDF) dist/ Frontend build output (gitignored; served by server + Tauri) public/ Static assets (appicon.png) -patches/ patch-package patches for node_modules Dockerfile Multi-stage web image (node → rust musl → alpine) docker-compose.yml Web deployment (volume /data, env reference) +docker-entrypoint.sh Container entrypoint (data-dir self-heal + boot diagnostics) +rust-toolchain.toml Pinned Rust toolchain (build reproducibility, REL-2) .env.example All server environment variables ``` @@ -132,8 +154,8 @@ docker-compose.yml Web deployment (volume /data, env reference) - **Trust boundary:** the Rust backend (desktop `src-tauri` / web `server`) is trusted; the frontend is not. File paths are canonicalized against the vault root, AI base URLs are allowlisted (SSRF guard), and API keys never reach the frontend — the webview cannot read them. - **Two runtimes, one frontend:** `frontend/lib/ipc.ts` abstracts Tauri IPC and HTTP/SSE behind one `invoke`/`listen` API, so components are runtime-agnostic. The web server reuses the desktop app's pure modules (`vault`, `wiki`, `git`, `search`, `agent`) via `#[path]` includes — never edit them in one place only. -- **Web auth:** first run creates an admin account (Argon2id); sessions are httpOnly cookies (rate-limited login). `DB_NO_AUTH=1` keeps open access (pre-web behavior). Env vars win over the Settings → System overrides. -- **API keys:** macOS Keychain on desktop; `keys.json` (0600) in `/data` on web. Both are resolved server-side in `ask_ai` — a frontend-supplied key is ignored. +- **Web auth:** first run creates an admin account (Argon2id); sessions are httpOnly cookies (rate-limited login) persisted in `sessions.json` (SHA-256 hashed tokens — they survive server restarts, so redeploys don't log users out). `DB_NO_AUTH=1` keeps open access (pre-web behavior); the setup wizard's "Skip — keep open access" is consent-gated (acknowledgement checkbox). Env vars win over the Settings → System overrides. +- **API keys:** macOS Keychain on desktop; `keys.json` (0600) in `/data` on web, optionally AES-256-GCM encrypted at rest via the `DB_KEYS_PASSPHRASE` env var (Argon2id-derived key; plaintext files auto-migrate, encrypted files are never overwritten without the passphrase). Both are resolved server-side in `ask_ai` — a frontend-supplied key is ignored. - **Permissions (desktop):** if you add or remove a Tauri command, regenerate `src-tauri/permissions/default.toml` and `src-tauri/capabilities/default.json` in the same change (see the header comment in the permission file). - **Versions:** `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` must stay in sync — CI enforces it. `server/Cargo.toml` is versioned independently. @@ -147,6 +169,22 @@ docker-compose.yml Web deployment (volume /data, env reference) - `npm run build` - `cd src-tauri && cargo test` - `cd server && cargo test` + - Build the web server + frontend, then the Playwright suites + (run logs land in `test/artifacts/` — server stdout/stderr, + browser console, and per-run results): + `cargo build --manifest-path server/Cargo.toml && npm run build` + `npm run test:e2e` # all suites, chromium (default) + `BROWSER=webkit npm run test:e2e` # webkit — CI only (macos-14 runner) + + Note — environment matrix (no ambiguity): + - macOS 12 (dev machine): the app minimum is macOS 12, validated by daily + development on it. Playwright 1.62+ ships mac14-only browser builds, so + the local chromium e2e uses the system Chrome fallback; webkit runs are + CI-only. + - macOS 14 (CI, macos-14 runner): full e2e matrix (chromium + webkit) with + the pinned Playwright builds — behavior validation on a newer supported + OS. A passing CI run is a superset check, not a claim about macOS 12 + internals; the minimum-OS claim rests on the dev machine itself. 4. Open a PR against `master` using the PR template. ### Commit conventions (enforced by the commit-msg hook) @@ -155,22 +193,24 @@ docker-compose.yml Web deployment (volume /data, env reference) (): ``` -| Type | Usage | -|------|-------| -| `feat` | new feature | -| `fix` | bug fix | -| `chore` | maintenance (release, deps) | -| `ci` | CI / pipeline | -| `docs` | documentation (README, CONTRIBUTING, CHANGELOG) | -| `perf` | performance optimization | -| `refactor` | structural change without behavior change | -| `test` | test suite / test tooling | -| `security` | security hardening / audit | - -- **Scope** is optional, kebab-case: `fix(docker):`, `ci(release):`, `feat(theme):` -- **Subject**: concise, imperative, lowercase — add a body for the WHY when needed +**DRY mapping — the commit subject IS the changelog line.** Each type maps 1:1 to a CHANGELOG category; a release section is assembled by grouping the merged PR subjects by type (no rewriting): + +| Type | CHANGELOG category | Usage | +|------|--------------------|-------| +| `feat` | 🚀 Features | new feature | +| `fix` | 🐛 Bug Fixes | bug fix | +| `security` | 🛡️ Security | security hardening / audit | +| `perf` | ⚡ Performance | performance optimization | +| `refactor` | 🔄 Refactor | structural change without behavior change | +| `docs` | 📚 Documentation | documentation (README, CONTRIBUTING) | +| `test` | 🧪 Testing & CI | test suite / test tooling | +| `ci` | 🔧 CI | CI / pipeline | +| `chore` | 🔄 Version / Hygiene | maintenance (release, deps) | + +- **Scope** is optional, kebab-case: `fix(docker):`, `ci(release):`, `feat(theme):` — when it adds signal, keep it as a prefix on the changelog bullet (`feat(theme):` → "theme: …") +- **Subject**: concise, imperative, lowercase — write it as the changelog line it will become - **PR merge commits** (squash) are exempt from the hook -- Commit messages are NOT used for auto-changelog (CHANGELOG.md is manual) — the convention keeps history readable +- **Release changelog = the merged PR subjects grouped by type** — each subject lands verbatim under its category in `CHANGELOG.md`; the section is assembled from commits, not rewritten (DRY) - The hook rejects other formats and lists the allowed types — no commitlint needed **CI runs the full artifact matrix on every PR** (not just on release): frontend build, desktop DMG, web server binary, and a full `docker build` of the web image (which also reports the image size). If your change touches the Dockerfile, the Rust modules, or the frontend, the PR build is the fastest way to catch breakage. diff --git a/Dockerfile b/Dockerfile index 02d1053..facd14b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,8 @@ COPY --from=server /src/server/target/release/docubook-server /app/docubook-serv COPY --from=web /app/dist /app/www COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh +# Runtime config (DB_*) is passed via compose/run/panel — only static +# defaults live here; the full variable list is in .env.example. ENV DATA_DIR=/data WWW_DIR=/app/www PORT=8080 # /data must exist with docubook ownership BEFORE first start: named volumes # inherit the mount-point ownership, so without this the volume is root-owned diff --git a/README.md b/README.md index 38cf146..f14dd3d 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,11 @@ How you set them depends on your host — a `.env` file for `docker compose`, `- | `DB_NO_AUTH` | `false` | `1` = open access without login (pre-web behavior) | | `DB_SESSION_TTL_HOURS` | `168` | Session lifetime before re-login | | `DB_ADMIN_EMAIL` + `DB_ADMIN_PASSWORD` | — | Set **both** to skip the wizard entirely (headless provisioning) | +| `DB_OPENAI_COMPAT_BASE_URL` (+ `_API_KEY`, `_MODEL`) | — | Provision/override the **custom OpenAI-compatible provider** from the environment. When `BASE_URL` is set, Settings → AI shows the custom endpoint **read-only** ("from env" badge) and the backend uses these values. Leave unset for the normal in-app setup | > [!NOTE] > `DB_SETUP_TOKEN` is compared verbatim — it is **not** a JWT, has no expiry beyond the setup window, and the wizard never displays it (it only asks for it). Generate one with `openssl rand -hex 32` and keep it safe. -All environment variables are documented in [`.env.example`](./.env.example). - **Requirements (web):** | Resource | Minimum | Recommended | Notes | @@ -150,9 +149,9 @@ spctl -a -t exec -vv /Applications/DocuBook.app ### First run (both) -- **Web**: open the URL → the setup wizard creates the admin account (or provision headless with `DB_ADMIN_EMAIL` + `DB_ADMIN_PASSWORD`, both required). Data lives in the `/data` volume — back it up. -- **Desktop**: open the app → welcome screen → **Open Folder** (an existing folder of `.md` files), **Create New Vault**, or **Clone Repository** (paste a git URL). Vaults are plain local folders — no lock-in. -- **Connect AI**: Settings → **AI** — pick a provider, paste your API key. Keys are stored **backend-side only** (macOS Keychain on desktop, a 0600 file in `/data` on web) and never leave the machine. +- **Web**: open the URL → the setup wizard creates the admin account (or provision headless with `DB_ADMIN_EMAIL` + `DB_ADMIN_PASSWORD`, both required). Back up the `/data` volume — see the persistence warning in [Option A](#option-a--web-docker-self-host). +- **Desktop**: open the app → welcome screen → **Open Folder** (an existing folder of `.md` files), **Create New Vault**, or **Clone Repository** (paste a git URL). +- **Connect AI**: Settings → **AI** — pick a provider, paste your API key (stored backend-side, never in the browser). Docker: optionally provision the custom endpoint headless with `DB_OPENAI_COMPAT_BASE_URL` + `_API_KEY` (+ `_MODEL`) — the UI then shows it read-only ("from env" badge). - **Publish with Git**: Settings → **Git** — set commit name/email and add a remote. Private repos use your Keychain / SSH keys on desktop; the container's git identity on web. - **Start writing**: click a file in the sidebar, type `/` for slash commands, use the **Code** button to toggle WYSIWYG/markdown. See [Usage](#usage). @@ -166,6 +165,9 @@ spctl -a -t exec -vv /Applications/DocuBook.app ## Features +> [!NOTE] +> Only **`.md` files** open in the WYSIWYG block editor (standard CommonMark). Other extensions (`.mdx`, `.markdown`, JSON, TOML, YAML, `.txt`, …) open in **view-only** mode. + ### Vault System (Obsidian-like) - Open any folder as a vault — your files stay local, no lock-in @@ -173,8 +175,6 @@ spctl -a -t exec -vv /Applications/DocuBook.app - CRUD — create files/folders, rename, delete via right-click context menu - Search files by filename (like Zed/Obsidian Cmd+F) - Frontmatter (YAML) auto-extracted, preserved during edits -- **.md** files open in WYSIWYG editor (fully supported) -- All other file types (`.mdx`, `.markdown`, JSON, TOML, YAML, etc.) open in view-only mode ### WYSIWYG Block Editor (Notion-like) @@ -182,16 +182,14 @@ spctl -a -t exec -vv /Applications/DocuBook.app - Slash command menu (`/`) to insert headings, lists, quotes, code blocks, dividers - Bubble menu for inline formatting (bold, italic, code, link, highlight) - Markdown source mode — toggle between WYSIWYG and raw markdown (code mode) -- **.md files only** — WYSIWYG mode supports standard CommonMark markdown -- Non-`.md` files (`.mdx`, `.markdown`, etc.) open in view-only mode ### AI Assistant - Inline AI powered by BlockNote XL (`@blocknote/xl-ai`) + custom Rust backend - Slash menu and toolbar AI commands: write, improve, summarize, translate, fix spelling, and more - Keyboard shortcut: `Ctrl+Alt+L` to open AI menu -- API keys configured in **Settings** — stored in macOS Keychain only, never localStorage -- **100+ providers** with **1,000+ models** — auto-synced from [models.dev](https://models.dev) into `frontend/data/providers.ts` (the generated catalog is the single source of truth; currently 174 providers / 5,482 models) +- API keys configured in **Settings** — stored **backend-side only** (macOS Keychain on desktop, a 0600 file in `/data` on web), never in localStorage +- **100+ providers** with **1,000+ models** — provider catalog generated from [models.dev](https://models.dev) via `node frontend/data/fetch-providers.mjs` (single source of truth; `--cache` reuses the last fetch offline) > [!NOTE]\ > **Every AI response becomes a reviewable suggestion.** The editor converts model output into `applyDocumentOperations` — either from the model's own tool call (`toolCall: true` models, the majority of the 1,000+ catalog) or generated from plain-text output (models without tool-call support, incl. `opencode-go`). In both cases the result appears as a tracked-change suggestion with **accept/reject** buttons before it touches the document. Output is guarded: referenced block ids must exist in the document (invalid ids trigger an automatic retry), and unclosed code fences are auto-closed before parsing. @@ -209,8 +207,6 @@ spctl -a -t exec -vv /Applications/DocuBook.app | Cohere | command-r7b, command-a | | Perplexity | sonar, sonar-pro | -**Provider data** is auto-generated from [models.dev/api.json](https://models.dev/api.json) — an open-source database of AI model specs, pricing, and capabilities. Run `curl https://models.dev/api.json` to get the latest data. - ### Git Integration - Save — stage all changes (git add -A) @@ -233,7 +229,7 @@ spctl -a -t exec -vv /Applications/DocuBook.app | -------- | ------------------------------- | | Frontend | React 19, TypeScript 6, Zustand | | UI | Tailwind CSS v4, Lucide icons | -| Editor | BlockNoteJS 0.52 (ProseMirror) | +| Editor | BlockNoteJS 0.53 (ProseMirror) | | Backend | Rust with Tauri v2 | | Build | Vite 8 + Rolldown | | Markdown | pulldown-cmark (Rust) | @@ -251,12 +247,9 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for prerequisites, building, cross-comp 1. Launch the app — click Open Vault (folder icon in sidebar) 2. Select a folder containing .md files 3. Click a file in the sidebar tree — opens in WYSIWYG editor -4. Type `/` for slash commands, select text for bubble formatting -5. Use Code button to toggle between editor / markdown source -6. Save — stages changes, Publish — commit + push -7. Toggle AI in toolbar for AI assistance - -> **Note:** Only `.md` files are fully supported in WYSIWYG mode. Other extensions (`.mdx`, `.markdown`, `.txt`, etc.) open in view-only mode. +4. Edit: `/` for slash commands, select text for bubble formatting, Code button toggles WYSIWYG/markdown (see [Features](#features) and [Keyboard Shortcuts](#keyboard-shortcuts)) +5. Save — stages changes, Publish — commit + push +6. Toggle AI in toolbar for AI assistance ### Keyboard Shortcuts @@ -267,8 +260,8 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for prerequisites, building, cross-comp | `Ctrl/Cmd+O` | Open vault / project folder | | `Ctrl/Cmd+Shift+E` | Toggle WYSIWYG / Markdown | | `Ctrl/Cmd+Z` / `+Shift+Z` / `+Y` | Undo / Redo | -| `Ctrl/Cmd+N` | New file | -| `Ctrl/Cmd+Alt+N` | New folder | +| `Ctrl/Cmd+Shift+F` (native also `Ctrl/Cmd+N`) | New file | +| `Ctrl/Cmd+Alt+Shift+F` (native also `Ctrl/Cmd+Alt+N`) | New folder | | `Ctrl+Alt+L` | Ask AI / Write with AI (opens AI menu at cursor) | | `Ctrl/Cmd+,` | Settings (AI + Git) | | `/` (in editor) | Slash command menu | diff --git a/SECURITY.md b/SECURITY.md index f544c89..c483451 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -23,6 +23,23 @@ Please include: The Rust backend (`src-tauri`) is the trust boundary; the webview is treated as untrusted. Reports involving path traversal, SSRF, XSS, key handling, or IPC authorization are especially welcome. +## Security model + +**Trust boundary & keys** — the Rust backend (desktop `src-tauri`, web `server/`) is the trust boundary; the webview/browser is untrusted. API keys are backend-only and never sent to the webview (`list_keys` returns provider names only, never key material). + +**API-key storage** +- Desktop: macOS Keychain (`security` CLI). +- Web (Docker): `keys.json` in `/data`, mode `0600`. **Plaintext by default**; set the `DB_KEYS_PASSPHRASE` env var to enable AES-256-GCM encryption at rest (key derived from the passphrase via Argon2id, fresh salt per file). +- The passphrase protects the file **at rest only** — the real API key still flows to the provider `Authorization` header per request. +- Migration & guards: a plaintext file auto-migrates to encrypted on first access when the passphrase is set; an encrypted file is **never overwritten** when the passphrase is missing; a wrong passphrase yields an error, never garbage. + +**Deployment hardening — decide at first boot** +- `DB_SETUP_TOKEN` — required on public deployments so no one can claim the admin account before you do. +- `DB_KEYS_PASSPHRASE` — set from first boot and keep it in your secret manager; losing it makes stored keys unrecoverable. +- Admin account — create it before exposing the server. "Skip for now — keep open access" is a deliberate, **consent-gated** choice: anyone with the URL gets full access (no login) until an admin exists and login is re-enabled in Settings. + +**Other controls** — path-traversal-safe vault paths (`safe_path`), SSRF-guarded AI base URLs (allowlist + loopback only), sanitized AI error messages (no provider/URL leakage), CSP, and a web-only server-side trash (`.trash/` inside the vault — persistent in `/data`, excluded from tree, search, and git staging). + ## Supported versions | Version | Supported | diff --git a/docker-compose.yml b/docker-compose.yml index c6a86d2..b70dcec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,12 @@ services: DB_NO_AUTH: "false" # "1"/"true" = open access without login (pre-web behavior) DB_SESSION_TTL_HOURS: "168" # session lifetime in hours (1–8760) DB_SECURE_COOKIE: "0" # "1" when HTTPS (Coolify TLS proxy) — Secure flag on session cookie + # ── Custom OpenAI-compatible provider (optional env override) ── + # Provision/override the "openai-compatible" custom provider from env; + # when BASE_URL is set the UI shows it READ-ONLY ("from env" badge). + # DB_OPENAI_COMPAT_BASE_URL: https://proxy.example.com/v1 + # DB_OPENAI_COMPAT_API_KEY: sk-... + # DB_OPENAI_COMPAT_MODEL: gpt-oss-20b restart: unless-stopped volumes: diff --git a/frontend/components/Editor.tsx b/frontend/components/Editor.tsx index 15377b3..116cd5f 100644 --- a/frontend/components/Editor.tsx +++ b/frontend/components/Editor.tsx @@ -1,24 +1,32 @@ import { useEffect, useState, useRef } from 'react' -import { useCreateBlockNote, SuggestionMenuController, getDefaultReactSlashMenuItems, FormattingToolbar, FormattingToolbarController, getFormattingToolbarItems, useExtensionState, useBlockNoteEditor, useComponentsContext, EditLinkButton, DeleteLinkButton, LinkToolbarController, type LinkToolbarProps } from '@blocknote/react' +import { useCreateBlockNote, SuggestionMenuController, getDefaultReactSlashMenuItems, FormattingToolbar, FormattingToolbarController, getFormattingToolbarItems, useExtensionState, useBlockNoteEditor, useComponentsContext, useExtension, useEditorState, DeleteLinkButton, LinkToolbarController, type LinkToolbarProps } from '@blocknote/react' +import { LinkToolbarExtension, FormattingToolbarExtension, ShowSelectionExtension } from '@blocknote/core/extensions' import { BlockNoteView } from '@blocknote/mantine' import '@blocknote/mantine/style.css' import '@blocknote/xl-ai/style.css' -import { createHeadingBlockSpec, BlockNoteSchema, defaultBlockSpecs } from '@blocknote/core' +import { createHeadingBlockSpec, BlockNoteSchema, defaultBlockSpecs, createExtension } from '@blocknote/core' +import { Plugin } from 'prosemirror-state' +import { Decoration, DecorationSet } from 'prosemirror-view' import { en as baseDict } from '@blocknote/core/locales' import { AIExtension, AIMenuController, AIToolbarButton, getAISlashMenuItems } from '@blocknote/xl-ai' import { en as aiDict } from '@blocknote/xl-ai/locales' -import { X, Undo2, Redo2, Sparkles, EyeOff, Command, Option, ChevronUp, ArrowBigUp, Folder, GitBranch, Link2, ExternalLink } from 'lucide-react' +import { X, Undo2, Redo2, Sparkles, EyeOff, Command, Option, ChevronUp, ArrowBigUp, Folder, GitBranch, Link2, Type, ExternalLink } from 'lucide-react' import { useEditorStore } from '../stores/editor' import { useVaultStore } from '../stores/vault' import OnboardingGuide, { isOnboardingDone } from './OnboardingGuide' import { invoke, listen, openDir } from '../lib/ipc' import { toast } from 'sonner' -import { buildApplyDocumentInput, AI_FORMATTING_RULES, MAX_AI_ATTEMPTS, validateOperationsSemantics, buildTaskFormattingRules, normalizeMarkdown } from '../utils/aiBlocks' +import { buildApplyDocumentInput, AI_FORMATTING_RULES, MAX_AI_ATTEMPTS, validateOperationsSemantics, buildTaskFormattingRules, normalizeMarkdown, isVaultGenerationIntent, buildVaultGroundingPrompt, buildEditSystemPrompt, AI_MARKDOWN_INSTRUCTION } from '../utils/aiBlocks' +import { uuid } from '../utils/uuid' import { useKeyboard } from '../hooks/useKeyboard' import { useGitStatus } from '../stores/gitStatus' -import { useAiSettings } from '../stores/aiSettings' +import { useAiSettings, CUSTOM_PROVIDER_ID } from '../stores/aiSettings' import { useTheme } from '../stores/theme' +/** Batch AI token deltas into one text-delta part per tick — fewer ProseMirror + * document writes while the AI types (smooth instead of janky streaming). */ +const AI_DELTA_BATCH_MS = 50 + /** Lazy-load the provider catalog (2.17 MB — keep it out of the initial bundle). */ let _providersCache: typeof import('../data/providers').PROVIDERS | null = null async function getProviders() { @@ -30,9 +38,12 @@ async function getProviders() { * intentionally NOT sent — the backend resolves it from the keychain (SEC-5). */ async function getAiConfig(): Promise<{ provider?: string; model?: string; baseUrl?: string }> { try { - const { provider, model } = useAiSettings.getState() - const p = provider ? (await getProviders()).find(x => x.id === provider) : undefined - return { provider: provider || undefined, model: model || undefined, baseUrl: p?.api } + const st = useAiSettings.getState() + const p = st.provider ? (await getProviders()).find(x => x.id === st.provider) : undefined + /** Custom OpenAI-compatible endpoints aren't in the catalog — their base URL + * lives in the store and is bound server-side at save time. */ + const baseUrl = p?.api || (st.provider === CUSTOM_PROVIDER_ID ? st.baseUrls[st.provider] : undefined) + return { provider: st.provider || undefined, model: st.model || undefined, baseUrl } } catch (e) { console.error('[ai] getAiConfig error:', e); return {} } } @@ -78,6 +89,37 @@ const getSchema = () => { return _schema } +/** Visual indicator for `[[wikilink]]` text: accent + underline + pointer so + * Cmd+Click navigation is discoverable. ProseMirror decorations only — the + * stored content stays literal `[[Title]]` (markdown round-trip untouched). */ +const wikilinkStyler = createExtension({ + key: 'wikilinkStyler', + prosemirrorPlugins: [ + new Plugin({ + props: { + decorations(state) { + const decos: Decoration[] = [] + const re = /\[\[([^\]]+)\]\]/g + state.doc.descendants((node, pos) => { + if (node.isText) { + const text = node.text || '' + let m: RegExpExecArray | null + while ((m = re.exec(text)) !== null) { + decos.push(Decoration.inline(pos + m.index, pos + m.index + m[0].length, { + 'data-wikilink': '1', + style: 'color: var(--color-accent); text-decoration: underline; cursor: pointer;', + })) + } + } + return true + }) + return DecorationSet.create(state.doc, decos) + }, + }, + }), + ], +}) + /** Welcome screen shown when no vault is open — launchpad (Open Folder / Create Vault / Recent). */ function WelcomeScreen() { const { recent, openRecent, openVault, createVault, cloneVault, loading } = useVaultStore() @@ -189,7 +231,14 @@ function WysiwygEditor({ markdown, onSync, filePath }: { markdown: string; onSyn const resolvedModel = config.model || st.model const providerInfo = (await getProviders()).find(p => p.id === resolvedProvider) const modelDef = providerInfo?.models.find(m => m.id === resolvedModel) - const supportsTools = modelDef?.toolCall === true && resolvedProvider !== 'opencode-go' + /** Tool-call support = model capability (catalog) AND measured gateway + * compatibility (test_connection probe, stored per provider+model). No + * static exclusions: a provider/model measured tools:false stays + * text-only, custom endpoints unlock when the probe measures tools:true. */ + const probe = st.probeTools[resolvedProvider]?.[resolvedModel] + const supportsTools = resolvedProvider === CUSTOM_PROVIDER_ID + ? probe === true + : modelDef?.toolCall === true && probe !== false const toolDefs = (body as any)?.toolDefinitions as Record | undefined /** Send xl-ai's OWN tool definitions (applyDocumentOperations) so operations → suggestions work */ const tools = (supportsTools && toolDefs) ? Object.entries(toolDefs).map(([name, def]) => ({ @@ -200,16 +249,33 @@ function WysiwygEditor({ markdown, onSync, filePath }: { markdown: string; onSyn const selText = sel?.blocks?.length ? editorRef.current.blocksToMarkdownLossy(sel.blocks) : ''; const stream = new ReadableStream({ async start(controller) { - const id = crypto.randomUUID() + const id = uuid() let fullText = '' controller.enqueue({ type: 'text-start', id }) let closed = false + /** Batch token deltas and flush on a short timer: one ProseMirror doc + * write per batch instead of per token is the difference between + * janky and smooth AI typing. fullText still accumulates per event. */ + let pendingDelta = '' + let flushTimer: ReturnType | undefined + const flushDeltas = () => { + flushTimer = undefined + if (closed || !pendingDelta) return + controller.enqueue({ type: 'text-delta', delta: pendingDelta, id }) + pendingDelta = '' + } const unsubToken = await listen('ai:token', e => { if (abortSignal?.aborted || closed) { try { controller.close() } catch {}; return } fullText += e.payload - controller.enqueue({ type: 'text-delta', delta: e.payload, id }) + pendingDelta += e.payload + if (!bufferText && !flushTimer) flushTimer = setTimeout(flushDeltas, AI_DELTA_BATCH_MS) }) const toolBuffer: any[] = [] + /** Point 5: Path A (tools sent) can mix text+tool calls in one response. + * Buffer text deltas and decide at the end — meaningful ops win + * (ops-only output, buffered commentary dropped), otherwise the + * buffered text is flushed. Path B (no tools) keeps live typing. */ + let bufferText = true const unsubTool = await listen('ai:tool_call', e => { if (abortSignal?.aborted || closed) return toolBuffer.push(e.payload) @@ -223,30 +289,36 @@ function WysiwygEditor({ markdown, onSync, filePath }: { markdown: string; onSyn const userMsg = messages.find((m: any) => m.role === 'user') const userText = (userMsg?.parts || []).map((p: any) => p.type === 'text' ? p.text : '').join('') || '' const taskRules = buildTaskFormattingRules(userText) - const systemGrounding = docContext - ? `You are editing the document below. Prefer updating existing blocks over adding new ones; reference block ids EXACTLY as shown. - -Document state (JSON): -${docContext} - -Rules (MUST follow): -- Output ONLY the new or modified content for the requested task. -- NEVER echo the document state JSON or block ids back into the output. -- NEVER repeat the user's prompt or these instructions. -- NEVER invent block ids or content that is not in the document; if the document lacks the needed information, state that instead of fabricating. -- Use only the exact block ids from the document above when referencing existing blocks. -- Output must be free of spelling and grammar errors. -- When editing or replacing selected blocks, PRESERVE each block's type and formatting (e.g., keep a heading as a heading with the same level, keep lists as lists, keep code blocks as code blocks). Change only the content unless the user explicitly asks to change the format.${taskRules}` + /** Resolve wikilinks + search vault for additional grounding context. + * Token-budgeted server-side (2k chars per file, 3 search results max). */ + let vaultContext = '' + try { + const activePath = useEditorStore.getState().activeTab || '' + vaultContext = await invoke('ai_grounding_context', { query: userText, activePath }) + } catch { /* no vault or no wiki index — skip grounding */ } + const hasVaultContext = vaultContext.trim().length > 0 + /** Vault-first generation: the edit rules below de-authorize vault + * content ("NEVER invent … content that is not in the document"), + * so a request referencing [[wikilinks]] / asking / generating / + * targeting an empty doc gets forced into an applyDocumentOperations + * edit with nothing to anchor on. Detect that intent → skip the + * tool path and use the vault context as the model's only source; + * output lands as plain-Markdown insert (accept/revert). */ + const isVaultGeneration = isVaultGenerationIntent(userText, hasVaultContext, docContext) + const useTools = supportsTools && !!tools && !isVaultGeneration + bufferText = useTools + const systemGrounding = isVaultGeneration + ? buildVaultGroundingPrompt(vaultContext) + : docContext + ? buildEditSystemPrompt(docContext, vaultContext, taskRules) : '' /** Base messages once; retry loop appends error feedback. */ let baseMsgs: any[] - if (supportsTools && tools) { + if (useTools) { const cleanMessages = messages.map((m: any) => ({ role: m.role, content: (m.parts || []).map((p: any) => p.type === 'text' ? p.text : '').join('') || m.content || '' })) baseMsgs = systemGrounding ? [{ role: 'system', content: systemGrounding }, ...cleanMessages] : cleanMessages } else { - const userContent = `${userText}${selText ? `\n\nSelected text:\n"${selText}"` : ''} - -Respond with the requested content using BlockNote-compatible Markdown. Use headings (##), code blocks (\`\`\`), bullet lists (-), numbered lists (1.), blockquotes (>). No commentary.` + const userContent = `${userText}${selText ? `\n\nSelected text:\n"${selText}"` : ''}\n\n${AI_MARKDOWN_INSTRUCTION}` baseMsgs = systemGrounding ? [{ role: 'system', content: systemGrounding }, { role: 'user', content: userContent }] : [{ role: 'user', content: userContent }] @@ -260,15 +332,20 @@ Respond with the requested content using BlockNote-compatible Markdown. Use head let emitText = '' while (attempts <= MAX_AI_ATTEMPTS) { fullText = '' + pendingDelta = '' toolBuffer.length = 0 const msgs = errorFeedback ? [...baseMsgs, { role: 'user', content: errorFeedback }] : baseMsgs await invoke('ask_ai', { messages: JSON.stringify(msgs), - ...(supportsTools && tools ? { tools: JSON.stringify(tools) } : {}), + ...(useTools ? { tools: JSON.stringify(tools) } : {}), provider: resolvedProvider, model: resolvedModel, - baseUrl: providerInfo?.api, + baseUrl: providerInfo?.api || config.baseUrl, }) + /** Diagnostic: this line must appear AFTER a completed ask_ai. + * If xl-ai errors but this never logs, the stream never + * resolved (stuck SSE) — not a transport branch failure. */ + console.info('[ai] ask_ai resolved', { chars: fullText.length, tools: toolBuffer.length }) /** Real correctness gate: referenced ids must exist in the document (blocking). */ let semanticError: string | null = null for (const tc of toolBuffer) { @@ -289,41 +366,80 @@ Respond with the requested content using BlockNote-compatible Markdown. Use head attempts++ } closed = true + /** Point 5: when the model produced meaningful tool ops they are the + * ONLY output channel — drop the buffered commentary text so the + * suggestion never overwrites/duplicates streamed prose. Otherwise + * flush (Path B already streamed live; Path A flushes now). */ + const meaningfulOps = accepted + ? emitToolCalls.filter((tc: any) => tc?.input && Array.isArray(tc.input.operations) && tc.input.operations.length > 0) + : [] + if (meaningfulOps.length > 0) { + pendingDelta = '' + } else { + flushDeltas() + } if (!accepted) { /** Signal the error to xl-ai so its AIMenu shows error state with retry/cancel * (built-in getDefaultAIMenuItemsForError renders retry + cancel buttons). */ const reason = lastReason || 'unknown' - console.error('[ai] AI output failed validation:', reason) - toast.error('AI output was rejected — retry or cancel in the AI menu') + console.error('[ai] AI output failed validation:', { provider: resolvedProvider, model: resolvedModel, supportsTools, attempts, reason, toolCalls: toolBuffer.length, textLen: fullText.length, textSnippet: fullText.substring(0, 300) }) + toast.error('AI output was rejected: ' + reason) controller.error(new Error(reason)) } else if (emitToolCalls.length > 0) { - for (const tc of emitToolCalls) { - /** Emit tool-input-available so xl-ai Chat creates a tool part → suggestions */ - controller.enqueue({ type: 'tool-input-available', toolCallId: tc.toolCallId, toolName: tc.toolName, input: tc.input }) + /** A model forced by tool_choice:"required" often calls with EMPTY + * operations when it decides nothing needs changing. xl-ai hard-fails + * on empty input ("No operations seen"), so filter those out and + * close gracefully instead of surfacing an error. */ + if (meaningfulOps.length === 0) { + console.info('[ai] tool calls had no operations — treating as no change', { provider: resolvedProvider, model: resolvedModel, toolCalls: emitToolCalls.length }) + /** Close the AI menu instead of finishing OK — xl-ai enters + * user-reviewing (empty accept/revert) on ANY successful call, + * so a no-change result must not "succeed" normally. Access + * the extension via editor.extensions (same as openXlAiMenu). */ + const aiExt = editorRef.current && (editorRef.current as any).extensions && (editorRef.current as any).extensions.get('ai') + if (aiExt && typeof aiExt.closeAIMenu === 'function') aiExt.closeAIMenu() + toast.info('AI made no document changes') + controller.enqueue({ type: 'text-end', id }) + } else { + for (const tc of meaningfulOps) { + /** Emit tool-input-available so xl-ai Chat creates a tool part → suggestions */ + controller.enqueue({ type: 'tool-input-available', toolCallId: tc.toolCallId, toolName: tc.toolName, input: tc.input }) + } + /** text-end only when a tool part was emitted (stream still open). */ + controller.enqueue({ type: 'text-end', id }) } - /** text-end only when a tool part was emitted (stream still open). */ - controller.enqueue({ type: 'text-end', id }) } else if (emitText && editorRef.current) { /** Text-only: build applyDocumentOperations so xl-ai renders a suggestion (Option A) */ - const input = await buildApplyDocumentInput(editorRef.current, emitText) + let input = await buildApplyDocumentInput(editorRef.current, emitText) + /** Path A (tools sent) produced text but not parseable markdown — + * e.g. empty document where model explains why it can't edit. + * Retry once with Path B prompt (no tools, explicit markdown + * instruction) before surfacing an error. */ if (input) { /** Let xl-ai create the tool part → suggestion → accept/reject flow */ - controller.enqueue({ type: 'tool-input-available', toolCallId: 'gen-' + crypto.randomUUID(), toolName: 'applyDocumentOperations', input }) + controller.enqueue({ type: 'tool-input-available', toolCallId: 'gen-' + uuid(), toolName: 'applyDocumentOperations', input }) controller.enqueue({ type: 'text-end', id }) } else { - /** Let xl-ai show error state (retry/cancel in AIMenu) instead of silently closing. */ - console.error('[ai] could not build document operations from AI output:', emitText.substring(0, 200)) - controller.error(new Error('AI output could not be converted to document operations')) + /** Text that can't be parsed into blocks: the streamed text is + * already written into the document (flushed above) — close + * cleanly so xl-ai enters user-reviewing (accept/revert) on it. + * No Path A→B retry: a text-only model always answers in text, + * so regenerating doubles latency and fails identically. */ + console.info('[ai] text kept as streamed result (not converted to blocks)', { provider: resolvedProvider, model: resolvedModel, textLen: emitText.length, textSnippet: emitText.substring(0, 200) }) + controller.enqueue({ type: 'text-end', id }) } } else { - /** Nothing to emit (e.g., empty accepted output) — close text part normally. */ - controller.enqueue({ type: 'text-end', id }) + /** Nothing to emit — empty output AND no tool calls = gateway + * anomaly (unlike a deliberate empty tool call, which is a + * no-change). Surface it as an error with the details logged. */ + console.error('[ai] empty AI result:', { provider: resolvedProvider, model: resolvedModel, supportsTools, attempts, lastReason, toolCalls: toolBuffer.length, textLen: fullText.length }) + controller.error(new Error('AI returned an empty response')) } } catch (e) { console.error('[ai] transport error:', e) try { controller.error(e) } catch {} } finally { - closed = true; unsubToken(); unsubTool(); unsubToolsDone(); try { controller.close() } catch {} + closed = true; if (flushTimer) clearTimeout(flushTimer); unsubToken(); unsubTool(); unsubToolsDone(); try { controller.close() } catch {} } } }) @@ -331,17 +447,95 @@ Respond with the requested content using BlockNote-compatible Markdown. Use head }, reconnectToStream: async () => null, }, - agentCursor: { name: 'DocuBook AI', color: 'var(--color-accent)' }, - })], + agentCursor: { name: 'DocuBook AI', color: 'var(--color-ai-cursor)' }, + }), wikilinkStyler], }, [markdown]) useEffect(() => { editorRef.current = editor }, [editor]) + /** Hover hint for [[wikilink]]: native title tooltips get cancelled by + * ProseMirror's decoration re-rendering, so render a small floating hint + * via event delegation (immune to span re-creation). */ + useEffect(() => { + const el = editor.domElement + if (!el) return + const tip = document.createElement('div') + tip.setAttribute('data-wikilink-tip', '1') + tip.textContent = 'Cmd+Click to open' + tip.style.cssText = 'position:fixed;z-index:9999;display:none;pointer-events:none;padding:3px 8px;border-radius:6px;font-size:11px;white-space:nowrap;background:var(--color-surface,#2a2a2c);color:var(--color-foreground,#fafafa);border:1px solid var(--color-border,#3a3a3c);box-shadow:0 4px 12px rgba(0,0,0,0.3);' + document.body.appendChild(tip) + const show = (x: number, y: number) => { tip.style.left = `${x + 10}px`; tip.style.top = `${y + 16}px`; tip.style.display = 'block' } + const hide = () => { tip.style.display = 'none' } + const onMouseOver = (e: MouseEvent) => { + const t = e.target as HTMLElement + if (t?.tagName === 'SPAN' && t.getAttribute('data-wikilink') === '1') show(e.clientX, e.clientY) + else hide() + } + el.addEventListener('mouseover', onMouseOver) + el.addEventListener('mouseleave', hide) + return () => { el.removeEventListener('mouseover', onMouseOver); el.removeEventListener('mouseleave', hide); tip.remove() } + }, [editor]) + + /** Cmd/Ctrl+Click on a `[[wikilink]]` opens the referenced note + * (Obsidian-style). Plain click keeps caret positioning for editing. */ + useEffect(() => { + const el = editor.domElement + if (!el) return + const onClick = (e: MouseEvent) => { + // Resolve the click position directly (caretRangeFromPoint) — Meta+click + // does not move the ProseMirror selection, so getSelection() is unreliable. + const range = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null + const node = range?.startContainer ?? null + const off = range?.startOffset ?? 0 + if (!node || node.nodeType !== Node.TEXT_NODE) return + const text = node.textContent || '' + const re = /\[\[([^\]]+)\]\]/g + let m: RegExpExecArray | null + while ((m = re.exec(text)) !== null) { + if (off >= m.index && off <= m.index + m[0].length) { + const title = m[1] + const open = () => { + invoke('wiki_resolve', { title }) + .then(path => { if (path) useEditorStore.getState().openFile(path, path.split('/').pop() || path) }) + .catch(() => {}) + } + if (e.metaKey || e.ctrlKey) { + e.preventDefault() + open() + } else { + // Single click without modifier: the pointer cursor promises an + // action — show a tooltip with an Open action instead of silence. + toast('Wikilink — Cmd+Click or Open to navigate', { + action: { label: 'Open', onClick: open }, + duration: 4000, + }) + } + break + } + } + } + el.addEventListener('click', onClick) + return () => el.removeEventListener('click', onClick) + }, [editor]) + /** Follow the AI writing position. xl-ai's built-in auto-scroll self-disables once content * outgrows the viewport (its scroll-event race kills `autoScroll` under streaming), so we * scroll the writing block ourselves and stop only on real user input (wheel/touch/keys). */ const aiMenu: any = useExtensionState(AIExtension, { editor, selector: (s: any) => s.aiMenuState }) const isAiWriting = !!aiMenu && aiMenu !== 'closed' && aiMenu.status === 'ai-writing' const followRef = useRef(true) + /** Mirrors isAiWriting for the onChange gate (avoids re-subscribing). */ + const aiWritingRef = useRef(false) + const prevAiWriting = useRef(false) + /** Settle tab-dirty + undo state once when AI writing ends — the per-flush + * onChange is gated during streaming (it fired per token write). */ + useEffect(() => { + if (prevAiWriting.current && !isAiWriting) { + useEditorStore.getState().setTabDirty(filePath, true) + useEditorStore.getState().setUndoRedoState() + } + prevAiWriting.current = isAiWriting + aiWritingRef.current = isAiWriting + }, [isAiWriting, filePath]) /** User scrolling (wheel/touch/scroll keys) stops the follower; re-armed on next AI run. */ useEffect(() => { @@ -360,19 +554,39 @@ Respond with the requested content using BlockNote-compatible Markdown. Use head } }, [isAiWriting]) - /** Token-level scroll: any DOM change in the editor while AI writes re-centers the writing block. */ + /** Token-level scroll: any DOM change in the editor while AI writes keeps the + * writing block in view. rAF-throttled AND viewport-aware — it only scrolls + * when the block actually leaves the visible area (minimal delta). Constant + * re-centering per frame was what made AI typing look janky. */ useEffect(() => { if (!isAiWriting || !aiMenu?.blockId) return const root = editor.domElement if (!root) return + let raf = 0 const scroll = () => { - if (!followRef.current) return - const el = root.querySelector(`[data-node-type="blockContainer"][data-id="${aiMenu.blockId}"]`) - el?.scrollIntoView({ block: 'center' }) + if (!followRef.current || raf) return + raf = requestAnimationFrame(() => { + raf = 0 + const el = root.querySelector(`[data-node-type="blockContainer"][data-id="${aiMenu.blockId}"]`) + if (!el) return + const box = el.getBoundingClientRect() + // Nearest scrollable ancestor — the editor's scroll container. + let scroller: HTMLElement | null = el.parentElement + while (scroller && scroller.scrollHeight <= scroller.clientHeight) scroller = scroller.parentElement + if (!scroller) { el.scrollIntoView({ block: 'nearest' }); return } + const cbox = scroller.getBoundingClientRect() + const margin = 32 + if (box.bottom > cbox.bottom - margin) { + scroller.scrollTop += box.bottom - (cbox.bottom - margin) // scroll down + } else if (box.top < cbox.top + margin) { + scroller.scrollTop -= (cbox.top + margin) - box.top // scroll up + } + // block fully in view — do nothing (no jump, no repaint) + }) } const mo = new MutationObserver(scroll) mo.observe(root, { childList: true, subtree: true, characterData: true }) - return () => mo.disconnect() + return () => { mo.disconnect(); if (raf) cancelAnimationFrame(raf) } }, [isAiWriting, aiMenu?.blockId, editor]) const { setBlockEditor, setFlushEditor } = useEditorStore() const onSyncRef = useRef(onSync) @@ -389,6 +603,7 @@ Respond with the requested content using BlockNote-compatible Markdown. Use head const sub = editor.onChange(() => { if (initialLoadRef.current) return dirtyRef.current = true + if (aiWritingRef.current) return // gate UI store spam during AI streaming — settled once at writing end useEditorStore.getState().setTabDirty(filePath, true) useEditorStore.getState().setUndoRedoState() }) @@ -469,28 +684,168 @@ async function openExternal(url: string) { } } +/** Shared link URL/text form — submits AS-TYPED (no https:// forcing). + * BlockNote's default EditLinkMenuItems.validateUrl prepends + * DEFAULT_LINK_PROTOCOL ("https") to any URL without a known scheme, which + * mangles vault-relative links: "./folder.md" → "https://./folder.md". + * Vault links must round-trip verbatim; bare web domains pasted into the + * editor are still https-ified by BlockNote's pasteHandler, so the form + * never needs to force a protocol. */ +function LinkUrlForm({ url, text, range, showTextField, onSubmitted }: { + url: string + text: string + range: { from: number; to: number } + showTextField?: boolean + onSubmitted: () => void +}) { + const Components = useComponentsContext()! + const { editLink } = useExtension(LinkToolbarExtension) + const [currentUrl, setCurrentUrl] = useState(url) + const [currentText, setCurrentText] = useState(text) + useEffect(() => { setCurrentUrl(url); setCurrentText(text) }, [url, text]) + const submit = () => { + editLink(currentUrl.trim(), currentText, range.from) + onSubmitted() + } + return ( + + } autoFocus + placeholder="https://… or ./folder.md" value={currentUrl} + onChange={e => setCurrentUrl(e.currentTarget.value)} + onSubmit={submit} + onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) { e.preventDefault(); submit() } }} /> + {showTextField !== false && ( + } + placeholder="Text" value={currentText} + onChange={e => setCurrentText(e.currentTarget.value)} + onSubmit={submit} + onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) { e.preventDefault(); submit() } }} /> + )} + + ) +} + +/** LinkToolbar "Edit" — preserves the URL as-typed (vault-relative links). */ +function EditLinkButtonPreserveUrl({ url, text, range, setToolbarOpen, setToolbarPositionFrozen }: Pick) { + const Components = useComponentsContext()! + return ( + + + + Edit + + + + { setToolbarOpen?.(false); setToolbarPositionFrozen?.(false) }} /> + + + ) +} + +/** Formatting-toolbar "Link" button (and Ctrl/Cmd+K) — same as-typed form. + * Replaces BlockNote's CreateLinkButton, which routes through the + * https-forcing EditLinkMenuItems. */ +function CreateLinkButtonPreserveUrl() { + const editor = useBlockNoteEditor() + const Components = useComponentsContext()! + const formattingToolbar = useExtension(FormattingToolbarExtension) + const { showSelection } = useExtension(ShowSelectionExtension) + const [showPopover, setShowPopover] = useState(false) + /** Keep the text selection while the popover is open (correct link range). */ + useEffect(() => { + showSelection(showPopover, "createLinkButton") + return () => showSelection(false, "createLinkButton") + }, [showPopover, showSelection]) + const state = useEditorState({ + editor, + selector: ({ editor }) => { + if (!editor.isEditable) return undefined + return { + url: editor.getSelectedLinkUrl() ?? '', + text: editor.getSelectedText(), + range: { + from: editor.prosemirrorState.selection.from, + to: editor.prosemirrorState.selection.to, + }, + } + }, + }) + useEffect(() => { setShowPopover(false) }, [state]) + /** Ctrl/Cmd+K opens the link form (same shortcut as the default button). */ + useEffect(() => { + const el = editor.domElement + if (!el) return + const cb = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); setShowPopover(true) } + } + el.addEventListener('keydown', cb) + return () => el.removeEventListener('keydown', cb) + }, [editor]) + if (state === undefined) return null + return ( + + + } + onClick={() => setShowPopover(o => !o)} /> + + + { setShowPopover(false); formattingToolbar.store.setState(false) }} /> + { + try { editor.insertInlineContent([{ type: 'text', text: `[[${title}]]`, styles: {} }] as any) } catch (e) { console.error('insert wikilink:', e) } + setShowPopover(false); formattingToolbar.store.setState(false) + }} /> + + + ) +} + /** LinkToolbar override: "open" on a link pointing to a vault note (relative * path, no scheme) opens the file in the app — not a browser tab. External * URLs open via the system opener (native) / new tab (web). - * Edit/Remove stay BlockNote defaults. */ + * Edit preserves the URL as-typed (vault-relative links stay intact). */ function WikiLinkToolbar({ url, text, range, setToolbarOpen, setToolbarPositionFrozen }: LinkToolbarProps) { const Components = useComponentsContext()! const openFile = useEditorStore(s => s.openFile) - const isVaultLink = !!url && !/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('#') && !url.startsWith('/') + const activeTab = useEditorStore(s => s.activeTab) + /** Vault link = no scheme and not protocol-relative (//host). Covers plain + * names, ./ and ../ (resolved against the ACTIVE file's folder — Obsidian + * semantics, NOT the vault root), and / (vault root). Absolute filesystem + * paths are excluded (no scheme check above rejects them; the server's + * safe_path also guards against any traversal). */ + const isVaultLink = !!url && !/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('//') + const open = () => { + if (!url) return + if (!isVaultLink) { openExternal(url); return } + const target = url.split('#')[0].split('?')[0] // strip #anchor / ?query + if (!target) return // anchor-only link + const curFile = activeTab ?? '' + const curDir = curFile.includes('/') ? curFile.substring(0, curFile.lastIndexOf('/')) : '' + const resolved = target.startsWith('/') + ? target.replace(/^\/+/, '') // /path → vault root + : (() => { + const parts: string[] = [] + for (const seg of [curDir, target].filter(Boolean).join('/').split('/')) { + if (seg === '..') parts.pop() + else if (seg === '.' || seg === '') continue + else parts.push(seg) + } + return parts.join('/') + })() + openFile(resolved, target.split('/').pop() || resolved) + } return ( { - if (!url) return - if (isVaultLink) openFile(url, url.split('/').pop() || url) - else openExternal(url) - }} + onClick={open} icon={} /> - + ) @@ -499,69 +854,51 @@ function WikiLinkToolbar({ url, text, range, setToolbarOpen, setToolbarPositionF /** Formatting toolbar (bubble menu) with the xl-ai button — shows the AI text prompt when text is selected. */ const FormattingToolbarWithAI = () => ( - {getFormattingToolbarItems()} - + {getFormattingToolbarItems().filter(el => (el as any).key !== 'createLinkButton')} + ) -/** "Link note" — search vault notes and insert a `[[wikilink]]` at the cursor. */ -function LinkNoteButton() { - const editor = useBlockNoteEditor() - const [open, setOpen] = useState(false) +/** "Link a note" — search vault notes (name + content via wiki_suggest) and + * pick → caller inserts a `[[wikilink]]`. Lives inside the merged link popover + * (one bubble-menu icon), not a separate button. */ +function NoteLinkSearch({ onPick }: { onPick: (title: string) => void }) { const [query, setQuery] = useState('') const [results, setResults] = useState<{ path: string; title: string }[]>([]) const [selected, setSelected] = useState(0) useEffect(() => { - if (!open || !query.trim()) { setResults([]); return } + if (!query.trim()) { setResults([]); return } const t = setTimeout(() => { invoke('wiki_suggest', { query: query.trim() }).then(s => { try { setResults(JSON.parse(s)); setSelected(0) } catch {} }).catch(() => {}) }, 150) return () => clearTimeout(t) - }, [query, open]) - - const insert = (title: string) => { - try { - editor.insertInlineContent([{ type: 'text', text: `[[${title}]]`, styles: {} }] as any) - } catch (e) { console.error('insert link:', e) } - setOpen(false); setQuery(''); setResults([]) - } + }, [query]) return ( - <> - - {open && ( -
setOpen(false)}> -
e.stopPropagation()} className="bg-surface border border-border rounded-lg w-[400px] max-h-[300px] overflow-hidden shadow-[0_25px_50px_-12px_rgba(0,0,0,0.4)]"> - setQuery(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter' && results[selected]) { e.preventDefault(); insert(results[selected].title) } - if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(i => Math.min(i + 1, results.length - 1)) } - if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(i => Math.max(i - 1, 0)) } - if (e.key === 'Escape') { e.preventDefault(); setOpen(false) } - }} - placeholder="Search notes to link…" - className="w-full bg-transparent border-b border-border px-3 py-2 text-sm text-foreground outline-none" /> -
- {results.length === 0 && query &&
No notes found
} - {results.map((r, i) => ( -
insert(r.title)} - onMouseEnter={() => setSelected(i)} - className={'px-3 py-1.5 text-sm cursor-pointer ' + (i === selected ? 'bg-surface-active text-foreground' : 'text-foreground-secondary')}> - {r.title} -
- ))} -
+
+
or link a vault note
+ setQuery(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && results[selected]) { e.preventDefault(); onPick(results[selected].title) } + if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(i => Math.min(i + 1, results.length - 1)) } + if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(i => Math.max(i - 1, 0)) } + }} + placeholder="Search notes to link…" + className="w-full bg-transparent border-b border-border px-1 py-1 text-sm text-foreground outline-none" /> +
+ {results.length === 0 && query &&
No notes found
} + {results.map((r, i) => ( +
onPick(r.title)} onMouseEnter={() => setSelected(i)} + className={'px-1 py-1 text-sm cursor-pointer rounded ' + (i === selected ? 'bg-surface-active text-foreground' : 'text-foreground-secondary')}> + {r.title}
-
- )} - + ))} +
+
) } diff --git a/frontend/components/Login.tsx b/frontend/components/Login.tsx index 4310c56..787cd45 100644 --- a/frontend/components/Login.tsx +++ b/frontend/components/Login.tsx @@ -35,7 +35,7 @@ export default function Login() { setEmail(e.target.value)} placeholder="Email" className={input} autoFocus /> {err &&
{err}
} - diff --git a/frontend/components/OnboardingGuide.tsx b/frontend/components/OnboardingGuide.tsx index f9c0d93..fc0fd08 100644 --- a/frontend/components/OnboardingGuide.tsx +++ b/frontend/components/OnboardingGuide.tsx @@ -1,4 +1,5 @@ import { FileText, Sparkles, GitBranch, Keyboard } from 'lucide-react' +import { isTauri } from '../lib/ipc' const ONBOARDING_KEY = 'docubook-onboarding-done' @@ -14,7 +15,7 @@ const steps = [ { icon: FileText, title: 'Create your first note', - body: 'Click the + button in the sidebar or press ⌘N to create a new note. Name it anything — the .md extension is added automatically.', + body: `Click the + button in the sidebar or press ${isTauri ? '\u2318N' : '\u2318\u21E7F'} to create a new note. Name it anything — the .md extension is added automatically.`, }, { icon: Keyboard, diff --git a/frontend/components/SettingsModal.tsx b/frontend/components/SettingsModal.tsx index f3d14d6..61ed607 100644 --- a/frontend/components/SettingsModal.tsx +++ b/frontend/components/SettingsModal.tsx @@ -2,21 +2,64 @@ import { useState, useEffect, useRef } from 'react' import { invoke, isTauri } from '../lib/ipc' import { toast } from 'sonner' import { X, Eye, EyeOff, Check, Loader, ChevronsUpDown, Search } from 'lucide-react' -import { useAiSettings } from '../stores/aiSettings' +import { useAiSettings, CUSTOM_PROVIDER_ID } from '../stores/aiSettings' import GitSettings from './GitSettings' import SystemSettings from './SystemSettings' import AppearanceSettings from './AppearanceSettings' import type { ProviderInfo } from '../data/providers' +/** Synthetic provider for user-configured OpenAI-compatible endpoints — NOT in the + * generated catalog (providers.ts is auto-generated from models.dev and would + * overwrite it). Base URL + key are bound server-side via set_custom_endpoint. */ +const CUSTOM_PROVIDER: ProviderInfo = { id: CUSTOM_PROVIDER_ID, name: 'OpenAI Compatible (Custom)', api: '', models: [] } + +/** Badge for providers currently on the text-only path (no tool-call + * streaming). Source of truth is the measured probe (aiSettings.probeTools): + * probe false → text-only; custom endpoints are text-only until probed true. + * Unprobed providers show no badge — permissive default sends tools. */ +const TextOnlyBadge = () => ( + + text-only + +) + +const isTextOnlyProvider = (id: string, model: string, probeTools: Record>) => { + const probe = model ? probeTools[id]?.[model] : undefined + return probe === false || (id === CUSTOM_PROVIDER_ID && probe !== true) +} + export default function SettingsModal({ onClose }: { onClose: () => void }) { const [section, setSection] = useState<'ai' | 'appearance' | 'git' | 'system'>('ai') - const { provider, model, savedProviders, models, + const { provider, model, savedProviders, models, probeTools, setProvider, setModel, clearApiKey, addSavedProvider, removeSavedProvider } = useAiSettings() /** API key is entered here but NEVER read back from the backend — * the key stays in the keychain (SEC-5: keys are backend-only). */ const [keyInput, setKeyInput] = useState('') + /** Custom base URL for the OpenAI-compatible provider (persisted in the store). */ + const [baseUrlInput, setBaseUrlInput] = useState('') + + /** Custom provider config from the backend — source "env" means Docker + * overrides via DB_OPENAI_COMPAT_* → the UI renders read-only. */ + const [customCfg, setCustomCfg] = useState<{ source: string; baseUrl?: string; hasKey: boolean; model?: string } | null>(null) + useEffect(() => { + let cancelled = false + invoke('custom_ai_config').then(s => { + if (cancelled) return + try { + const cfg = JSON.parse(s) + setCustomCfg(cfg) + // Env model is forced — sync the store so transport + probe align. + if (cfg.source === 'env' && cfg.model) useAiSettings.getState().setModel(cfg.model) + } catch {} + }).catch(() => {}) + return () => { cancelled = true } + }, []) + const envCustom = customCfg?.source === 'env' + const envBadge = from env + /** Provider catalog lazy-loaded (2.17 MB — not part of the initial bundle). */ const [providers, setProviders] = useState([]) const providersRef = useRef([]) @@ -24,8 +67,8 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) { let cancelled = false import('../data/providers').then(m => { if (cancelled) return - providersRef.current = m.PROVIDERS - setProviders(m.PROVIDERS) + providersRef.current = [CUSTOM_PROVIDER, ...m.PROVIDERS] + setProviders([CUSTOM_PROVIDER, ...m.PROVIDERS]) }) return () => { cancelled = true } }, []) @@ -51,9 +94,17 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) { const providerListRef = useRef(null) const modelListRef = useRef(null) - const selectedProvider: ProviderInfo | null = provider ? providers.find(p => p.id === provider) || null : null + const selectedProvider: ProviderInfo | null = provider + ? provider === CUSTOM_PROVIDER_ID ? CUSTOM_PROVIDER : providers.find(p => p.id === provider) || null + : null + const isCustom = provider === CUSTOM_PROVIDER_ID const savedSet = new Set(savedProviders) + /** Keep the custom base URL input in sync with the selected provider. */ + useEffect(() => { + setBaseUrlInput(useAiSettings.getState().baseUrls[provider] || '') + }, [provider]) + /** Resync which providers have saved keys — one batch call instead of one invoke per provider. */ useEffect(() => { (async () => { @@ -96,29 +147,53 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) { const selectProviderFn = (p: ProviderInfo) => { setProvider(p.id) // restores saved apiKey + model for this provider - if (!models[p.id]) { import('../data/providers').then(m => { if (!useAiSettings.getState().models[p.id]) setModel(m.getDefaultModel(p.id) || '') }) } // only default if never chosen + if (p.id !== CUSTOM_PROVIDER_ID && !models[p.id]) { import('../data/providers').then(m => { if (!useAiSettings.getState().models[p.id]) setModel(m.getDefaultModel(p.id) || '') }) } // only default if never chosen setShowProviderDropdown(false) } const handleSave = async () => { - if (!provider || !keyInput) return + if (!provider || !keyInput || (isCustom && !baseUrlInput.trim())) return setSaving(true) try { - await invoke('set_api_key', { provider, key: keyInput }) + if (isCustom) { + await invoke('set_custom_endpoint', { provider, baseUrl: baseUrlInput.trim(), key: keyInput }) + useAiSettings.getState().setBaseUrl(baseUrlInput.trim()) + } else { + await invoke('set_api_key', { provider, key: keyInput }) + } addSavedProvider(provider) setKeyInput('') toast.success('API key saved') } catch (e) { toast.error('Failed to save API key'); console.error(e) } setSaving(false) + // Auto-probe right after saving so the badge + transport use MEASURED + // tool-call support, not the conservative default (unmeasured → text-only). + const p = providers.find(x => x.id === provider) + const testModel = model || p?.models[0]?.id || '' + try { + const result = await invoke('test_connection', { provider, model: testModel, baseUrl: isCustom ? baseUrlInput.trim() : p?.api || '', apiKey: keyInput }) + let tools: boolean | undefined + try { const parsed = JSON.parse(result); if (typeof parsed.tools === 'boolean') tools = parsed.tools } catch {} + if (tools !== undefined) { + useAiSettings.getState().setProbeTools(provider, testModel, tools) + toast.success(tools === true ? 'Tool calls supported' : 'Text-only — tool calls rejected by this gateway') + } + } catch { /* probe failed — badge stays at the default; Test button remains available */ } } const handleTest = async () => { - if (!provider || !keyInput) return + if (!provider || (!keyInput && !envCustom) || (isCustom && !baseUrlInput.trim() && !envCustom)) return setTesting(true) try { const p = providers.find(x => x.id === provider) - await invoke('test_connection', { provider, model: model || p?.models[0]?.id || '', baseUrl: p?.api || '', apiKey: keyInput }) - toast.success('Connection OK') + const testModel = model || p?.models[0]?.id || '' + const result = await invoke('test_connection', { provider, model: testModel, baseUrl: isCustom ? baseUrlInput.trim() : p?.api || '', apiKey: keyInput }) + /** Persist the measured tool-call support — ground truth for the transport + * (probe-based; no static exclusions). */ + let tools: boolean | undefined + try { const parsed = JSON.parse(result); if (typeof parsed.tools === 'boolean') tools = parsed.tools } catch {} + if (tools !== undefined) useAiSettings.getState().setProbeTools(provider, testModel, tools) + toast.success(tools === true ? 'Connection OK — tool calls supported' : 'Connection OK') } catch (e) { toast.error(String(e)) } setTesting(false) } @@ -157,7 +232,11 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) { setShowProviderDropdown(o => !o); setTimeout(() => searchRef.current?.focus(), 50) }} className={'flex items-center gap-2 bg-background border border-border rounded-md px-3 py-[7px] cursor-pointer text-[13px] ' + (provider ? 'text-foreground' : 'text-muted')}> - {selectedProvider ? selectedProvider.name + (savedSet.has(selectedProvider.id) ? ' ✓' : '') : '— Select a provider —'} + + {selectedProvider ? {selectedProvider.name} : '— Select a provider —'} + {selectedProvider && isTextOnlyProvider(selectedProvider.id, model, probeTools) && } + {selectedProvider && savedSet.has(selectedProvider.id) && } +
{showProviderDropdown && providerDropdownPos && ( @@ -178,6 +257,7 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
selectProviderFn(p)} className={'flex items-center gap-2 px-3 py-[7px] cursor-pointer text-[13px] ' + (provider === p.id ? 'bg-accent text-white' : i === providerHighlightIdx ? 'bg-surface-active text-foreground-secondary' : 'text-foreground-secondary')}> {p.name} + {isTextOnlyProvider(p.id, model, probeTools) && } {savedSet.has(p.id) && }
))} @@ -186,11 +266,24 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) { )} - {/* Model */} + {/* Model / custom endpoint */} {selectedProvider && ( <> - -
+ {isCustom ? ( + <> + + setBaseUrlInput(e.target.value)} readOnly={envCustom} + placeholder="https://proxy.example.com/v1 — OpenAI-compatible endpoint" + className="w-full bg-background border border-border rounded-md px-3 py-[7px] text-xs text-foreground outline-none font-mono mb-3 disabled:opacity-60" /> + + setModel(e.target.value)} readOnly={envCustom} + placeholder="model id, e.g. gpt-oss-20b or llama3.1:8b" + className="w-full bg-background border border-border rounded-md px-3 py-[7px] text-xs text-foreground outline-none font-mono mb-3 disabled:opacity-60" /> + + ) : ( + <> + +
{ const r = modelRef.current?.getBoundingClientRect() if (r) setModelDropdownPos({ position: 'fixed', top: r.bottom + 4, left: r.left, right: window.innerWidth - r.right, width: r.width }) @@ -232,24 +325,26 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
)}
+ + )} {/* API Key */} - +
- setKeyInput(e.target.value)} placeholder={savedSet.has(provider) ? 'Key saved — type a new key to replace it' : 'sk-...'} - className="w-full bg-background border border-border rounded-md pl-3 pr-10 py-[7px] text-xs text-foreground outline-none font-mono" /> + setKeyInput(e.target.value)} readOnly={envCustom} placeholder={envCustom ? 'Key provided by environment' : (savedSet.has(provider) ? 'Key saved — type a new key to replace it' : 'sk-...')} + className="w-full bg-background border border-border rounded-md pl-3 pr-10 py-[7px] text-xs text-foreground outline-none font-mono disabled:opacity-60" />
- -
} - {selectedProvider &&
Base URL: {selectedProvider.api}
} + {selectedProvider.api &&
Base URL: {selectedProvider.api}
} )} diff --git a/frontend/components/SetupWizard.tsx b/frontend/components/SetupWizard.tsx index ac3f765..c50ef17 100644 --- a/frontend/components/SetupWizard.tsx +++ b/frontend/components/SetupWizard.tsx @@ -44,6 +44,10 @@ export default function SetupWizard() { } catch (e) { toast.error(String(e)); setBusy(false) } } + /** Explicit consent gate: "keep open access" means anyone with the URL can + * use this server — the skip button stays disabled until acknowledged. */ + const [ackOpen, setAckOpen] = useState(false) + const input = 'w-full bg-background border border-border rounded-md px-3 py-2 text-[13px] text-foreground outline-none focus:border-accent' const btn = 'w-full flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm cursor-pointer transition-colors disabled:opacity-40' @@ -63,11 +67,16 @@ export default function SetupWizard() { setToken(e.target.value)} placeholder="Setup token (DB_SETUP_TOKEN)" className={input} /> )} {err &&
{err}
} - -
diff --git a/frontend/components/ShortcutsModal.tsx b/frontend/components/ShortcutsModal.tsx index 2f37a65..a8fa40e 100644 --- a/frontend/components/ShortcutsModal.tsx +++ b/frontend/components/ShortcutsModal.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react' import { X, Command, ArrowBigUp, Option, ChevronUp, ArrowUp, ArrowDown } from 'lucide-react' +import { isTauri } from '../lib/ipc' /** Render shortcut key glyphs (⌘⇧⌥⌃↑↓) as lucide icons — consistent with TabBar tooltips. */ const KEY_ICONS: Record = { @@ -62,8 +63,11 @@ const SHORTCUTS = [ { keys: '``` + space', desc: 'Toggle code block' }, ]}, { category: 'Files', items: [ - { keys: '\u2318N', desc: 'New file' }, - { keys: '\u2318\u2325N', desc: 'New folder' }, + /** Canonical ⌘⇧F / ⌘⌥⇧F work on every platform (browsers reserve ⌘N / + * ⌘⇧N / ⌘⌥N — new/private window — and never deliver them to the page). + * Native keeps ⌘N / ⌘⌥N as an alias, shown here per platform. */ + { keys: isTauri ? '\u2318N / \u2318\u21E7F' : '\u2318\u21E7F', desc: 'New file' }, + { keys: isTauri ? '\u2318\u2325N / \u2318\u2325\u21E7F' : '\u2318\u2325\u21E7F', desc: 'New folder' }, ]}, ] diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index beba8cd..860f38c 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -1,8 +1,8 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { useVaultStore } from '../stores/vault' import { useEditorStore } from '../stores/editor' -import { invoke } from '../lib/ipc' -import { Search, Folder, FileText, FolderOpen, Plus, X, Command, Settings, Option, PanelLeftClose } from 'lucide-react' +import { invoke, isTauri } from '../lib/ipc' +import { Search, Folder, FileText, FolderOpen, Plus, X, Command, Settings, Option, PanelLeftClose, Trash, RotateCcw, ArrowBigUp } from 'lucide-react' import { toast } from 'sonner' import SettingsModal from './SettingsModal' import { useClickOutside } from '../hooks/useClickOutside' @@ -157,6 +157,25 @@ export default function Sidebar({ onToggleSidebar }: { onToggleSidebar: () => vo const [renaming, setRenaming] = useState<{path:string;name:string}|null>(null) const renameRef = useRef(null) const [currentFolder, setCurrentFolder] = useState('') + /** Server-side trash (web only — native uses the system Trash/Finder). */ + const [trashOpen, setTrashOpen] = useState(false) + const [trashItems, setTrashItems] = useState<{name:string;original:string;deleted_at:number}[]>([]) + const loadTrash = useCallback(async () => { + try { + // web invoke returns the JSON as a string (must parse); desktop returns + // the parsed array — normalize both, never let a non-array reach .map + const raw = await invoke('list_trash') + const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw + setTrashItems(Array.isArray(parsed) ? parsed : []) + } catch(e) { console.error(e); setTrashItems([]) } + }, []) + const toggleTrash = async () => { if (trashOpen) { setTrashOpen(false); return } await loadTrash(); setTrashOpen(true) } + const restoreItem = async (item: {name:string;original:string;deleted_at:number}) => { + try { await invoke('restore_file', { trashName: item.name }); toast.success('Restored ' + item.original); await loadTrash(); loadTree() } catch(e) { console.error(e); toast.error('Restore failed') } + } + const emptyTrash = async () => { + try { await invoke('empty_trash'); setTrashItems([]); loadTree() } catch(e) { console.error(e); toast.error('Failed to empty trash') } + } useEffect(() => { if (renaming) setTimeout(() => renameRef.current?.focus(), 50) @@ -164,25 +183,32 @@ export default function Sidebar({ onToggleSidebar }: { onToggleSidebar: () => vo // Keyboard shortcuts useKeyboard((e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key === 'f') { e.preventDefault() if (!isOpen) { toast.error('Open a vault first — press ⌘O'); return } setSearchOpen(true) } - if ((e.metaKey || e.ctrlKey) && e.key === 'o') { e.preventDefault(); openVault() } - if ((e.metaKey || e.ctrlKey) && e.key === 'p') { + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key === 'o') { e.preventDefault(); openVault() } + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key === 'p') { e.preventDefault() if (!isOpen) { toast.error('Open a vault first — press ⌘O'); return } setSearchOpen(true) } - if ((e.metaKey || e.ctrlKey) && e.key === ',') { e.preventDefault(); setSettingsOpen(true) } + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key === ',') { e.preventDefault(); setSettingsOpen(true) } if (e.key === 'Escape' && settingsOpen) { setSettingsOpen(false) } - if ((e.metaKey || e.ctrlKey) && e.code === 'KeyN' && !e.shiftKey && !e.altKey) { + /** New file/folder. Canonical (all platforms): ⌘⇧F / ⌘⌥⇧F — browsers + * reserve ⌘N / ⌘⇧N / ⌘⌥N (new window / private window) and never deliver + * them to the page, so web only ever sees the canonical mapping. Native + * keeps ⌘N / ⌘⌥N as a bonus alias for the same actions. */ + const mod = e.metaKey || e.ctrlKey + const newFile = (mod && e.shiftKey && !e.altKey && e.code === 'KeyF') || (mod && !e.shiftKey && !e.altKey && e.code === 'KeyN') + const newFolder = (mod && e.shiftKey && e.altKey && e.code === 'KeyF') || (mod && !e.shiftKey && e.altKey && e.code === 'KeyN') + if (newFile) { e.preventDefault() if (!isOpen) { toast.error('Open a vault first — press ⌘O'); return } setCreating('file'); setNewName('') } - if ((e.metaKey || e.ctrlKey) && e.altKey && e.code === 'KeyN') { + if (newFolder) { e.preventDefault() if (!isOpen) { toast.error('Open a vault first — press ⌘O'); return } setCreating('folder'); setNewName('') @@ -191,11 +217,13 @@ export default function Sidebar({ onToggleSidebar }: { onToggleSidebar: () => vo // Refresh tree on window focus useEffect(() => { - if (!isOpen) return + if (!isOpen || isTauri) return + // Read the actual trash contents so the Trash button reflects real state. + loadTrash() const h = () => loadTree() window.addEventListener('focus', h) return () => window.removeEventListener('focus', h) - }, [isOpen, loadTree]) + }, [isOpen, loadTree, loadTrash]) return (