Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
name: Desktop build

# Builds the OpenKB desktop app (Tauri shell + frozen openkb-api sidecar) for
# every platform, and — on a `desktop-v*` tag — attaches the installers to a
# GitHub Release.
#
# Neither half cross-compiles — PyInstaller freezes for the host OS/arch and
# Tauri links the platform WebView — so each target is its own runner. macOS
# arm64/x86_64 are separate runners (native builds; no --target needed).
#
# Ordering within a job matters: build the web UI FIRST (npm run build →
# openkb/web), THEN freeze the sidecar (build_sidecar.sh bundles openkb/web),
# THEN `tauri build` (bundles the sidecar dir as a resource).

on:
workflow_dispatch:
push:
tags:
- "desktop-v*"

permissions:
contents: read

concurrency:
group: desktop-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
target: linux
- os: windows-latest
target: windows
- os: macos-14 # Apple Silicon
target: macos-arm64
- os: macos-13 # Intel
target: macos-intel
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
persist-credentials: false

# Tauri's Linux WebView + bundler dependencies. Windows (WebView2) and
# macOS (WebKit) ship theirs with the OS, so this is Linux-only.
- name: Install Linux WebView deps
if: matrix.target == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev \
build-essential curl wget file libxdo-dev libssl-dev

- uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0
with:
python-version: "3.12"

# openkb.api (the sidecar) + PyInstaller. PyInstaller is a build tool, not
# a runtime dep, so it's installed ad-hoc rather than pinned in pyproject.
- name: Install openkb + build tools
run: pip install -e ".[api]" pyinstaller

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"

# MUST run before the sidecar freeze — build_sidecar.sh bundles openkb/web.
- name: Build web UI
working-directory: frontend
run: |
npm ci
npm run build

# `shell: bash` so the same script runs on Windows (git-bash) too.
# PyInstaller emits openkb-api-sidecar(.exe) for the host OS/arch.
- name: Freeze API sidecar
shell: bash
run: PYTHON=python bash desktop/packaging/build_sidecar.sh

# ubuntu/macos/windows runners ship a stable Rust toolchain. tauri-cli is
# installed from crates.io (locked) rather than via a third-party action,
# to keep the supply chain explicit.
- name: Install tauri-cli
run: cargo install tauri-cli --version "^2" --locked

- name: Build Tauri bundle
working-directory: desktop/src-tauri
run: cargo tauri build

# TODO: pin actions/upload-artifact to a SHA to match the repo policy.
- name: Upload installers
uses: actions/upload-artifact@v4
with:
name: openkb-desktop-${{ matrix.target }}
path: |
desktop/src-tauri/target/release/bundle/**/*.AppImage
desktop/src-tauri/target/release/bundle/**/*.deb
desktop/src-tauri/target/release/bundle/**/*.rpm
desktop/src-tauri/target/release/bundle/**/*.dmg
desktop/src-tauri/target/release/bundle/**/*.msi
desktop/src-tauri/target/release/bundle/**/*.exe
if-no-files-found: warn

# Attach every platform's installers to a GitHub Release, but only for a
# `desktop-v*` tag. Signing/notarization (macOS Gatekeeper, Windows
# SmartScreen) is not configured yet — these are unsigned artifacts.
release:
if: startsWith(github.ref, 'refs/tags/desktop-v')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
# TODO: pin actions/download-artifact to a SHA to match the repo policy.
- name: Download all installers
uses: actions/download-artifact@v4
with:
path: installers
merge-multiple: true

- name: Create / update the release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
files: installers/**/*
8 changes: 8 additions & 0 deletions desktop/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# PyInstaller sidecar build artifacts
packaging/build/
packaging/dist/
packaging/*.spec

# Tauri / Rust build artifacts
src-tauri/target/
src-tauri/gen/
103 changes: 103 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# OpenKB Desktop (Tauri)

A desktop app that wraps OpenKB for non-technical users: no `pip`, no terminal.
It is a thin [Tauri](https://tauri.app) (Rust) shell around two things OpenKB
already produces:

1. **The API sidecar** — `openkb-api` (the FastAPI server from the Knowledge
Workbench, `openkb.api:main`) frozen into a self-contained binary with
PyInstaller. It serves both the JSON/SSE API and the built web UI.
2. **The web UI** — the `frontend/` Vite SPA, served by the sidecar at `/`.

```
┌─────────────────────────────────────────────┐
│ Tauri shell (Rust + system WebView) │
│ │
│ spawns ──▶ openkb-api-sidecar (frozen) │
│ ├─ FastAPI /api/v1/* │
│ └─ web UI / │
│ WebView ──▶ http://127.0.0.1:<port>/ │
└─────────────────────────────────────────────┘
```

The user double-clicks the app; the Rust shell starts the sidecar on a
localhost port, waits until it answers, then points the WebView at it. No
browser, no port, no "server" ever surfaces to the user.

## Why this shape

- OpenKB's capability lives in a heavy Python stack (litellm, pageindex,
markitdown, pymupdf). Rust can't replace that, so the Python runs as a frozen
sidecar. The Rust shell is glue: window, WebView, process lifecycle, updater.
- Tauri (vs Electron) ships no Chromium — smaller download, less memory — at the
cost of using each platform's system WebView.

## Layout

```
desktop/
packaging/ # freeze the Python API into a slim sidecar binary
build_sidecar.sh # PyInstaller recipe (slim: no magika/onnxruntime)
sidecar_entry.py # frozen entry -> openkb.api:main
pyi_rthook_magika.py# runtime hook: stub magika (drops onnxruntime)
prune_litellm_proxy.py # post-build: delete the unused LiteLLM proxy server
src-tauri/ # Tauri (Rust) shell — spawns the sidecar, opens the window
```

## Build pipeline

```bash
# 1. Python env with API + PyInstaller
python -m venv .venv && . .venv/bin/activate
pip install -e ".[api]" pyinstaller

# 2. Build the web UI (its output is what the sidecar serves)
cd frontend && npm install && npm run build && cd ..

# 3. Freeze the slim API sidecar -> desktop/packaging/dist/openkb-api-sidecar/
PYTHON=.venv/bin/python desktop/packaging/build_sidecar.sh

# 4. Build the Tauri app (bundles the sidecar + opens the WebView)
cd desktop/src-tauri && cargo tauri build
```

## Slimming (measured, x86_64 Linux)

The sidecar reuses the packaging work from the CLI slimming (see PR #186 for the
lazy-markitdown source change that makes it possible):

| stage | compressed |
|---|---|
| full PyInstaller freeze | 147 MB |
| − magika / onnxruntime (stub hook) | 140 MB |
| − LiteLLM proxy server | 133 MB |

The frozen **API sidecar** (adds FastAPI + uvicorn over the CLI baseline)
measures **≈134 MB compressed / 338 MB on disk**, verified booting uvicorn and
serving `GET /api/v1/kbs` → 200.

`pymupdf` (PDF engine) and `pandas`/`numpy` (Excel support) are kept
deliberately — they are load-bearing for OpenKB's document formats.

## Toolchain / status

Full builds need, per platform: Rust + Cargo, Node + npm, a Python 3.10+ env,
and on **Linux `webkit2gtk`** (`libwebkit2gtk-4.1-dev`) for the WebView.

CI: `.github/workflows/desktop.yml` runs this pipeline on `workflow_dispatch`
and `desktop-v*` tags (Linux only for now; the matrix has commented Windows /
macOS entries).

**Status:**
- **API sidecar** (`packaging/`) — implemented and verified: the frozen slim
binary boots uvicorn and serves both `GET /api/v1/kbs` and the web UI at `/`.
- **Web UI** — builds (`npm run build` → `openkb/web/`), is bundled into the
sidecar freeze, and renders end-to-end (verified via headless Chromium).
- **Tauri shell** (`src-tauri/`) — compiles and runs: verified under `xvfb`
(WebKitGTK) navigating to the sidecar and rendering the desktop TitleBar
(`window.__OPENKB_DESKTOP__` is injected by the shell).
- **Installers** — `cargo tauri build` produces a Linux `.deb` and `.rpm`
(verified locally; each ~175 MB, bundling the app + frozen sidecar). The
`.AppImage` target additionally downloads `appimagetool` at build time, so it
needs unrestricted network (fine on GitHub runners; blocked in a locked-down
sandbox). Windows/macOS installers build once their matrix runners are added.
43 changes: 43 additions & 0 deletions desktop/packaging/build_sidecar.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# Build the OpenKB API sidecar: a self-contained, slimmed PyInstaller binary
# that the Tauri desktop shell spawns and talks to over localhost HTTP.
#
# Slimming (see ../README.md for measured sizes):
# - exclude magika + onnxruntime; a runtime hook stubs magika so markitdown
# still converts Office/HTML by file extension.
# - post-build prune of the unused LiteLLM Proxy Server.
#
# Prerequisites: a Python env with `pip install -e ".[api]"` and `pyinstaller`.
# Set PYTHON to that env's interpreter (default: python3).
#
# Usage: PYTHON=/path/to/venv/bin/python desktop/packaging/build_sidecar.sh
# Output: desktop/packaging/dist/openkb-api-sidecar/
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON="${PYTHON:-python3}"
cd "$HERE"

rm -rf build dist openkb-api-sidecar.spec

# `openkb` is collected whole so PyInstaller bundles (a) its data files —
# crucially the built web UI at openkb/web that the API serves at `/`, plus
# prompt/template assets — and (b) submodules reached only via lazy/dynamic
# imports (e.g. the deferred `from openkb.converter import ...`). The web UI
# must be built (npm run build → openkb/web) BEFORE this script runs.
COLLECT=(openkb litellm markitdown pageindex tiktoken agents openai tiktoken_ext uvicorn fastapi)
EXCLUDE=(magika onnxruntime)

ARGS=(--onedir --name openkb-api-sidecar --noconfirm --clean --log-level=WARN)
ARGS+=(--runtime-hook "$HERE/pyi_rthook_magika.py")
for pkg in "${COLLECT[@]}"; do ARGS+=(--collect-all "$pkg"); done
for pkg in "${EXCLUDE[@]}"; do ARGS+=(--exclude-module "$pkg"); done

echo ">>> freezing openkb-api sidecar (slim: no magika/onnxruntime)"
"$PYTHON" -m PyInstaller "${ARGS[@]}" sidecar_entry.py

echo ">>> pruning unused litellm proxy server"
"$PYTHON" prune_litellm_proxy.py "dist/openkb-api-sidecar"

echo ">>> done: dist/openkb-api-sidecar/openkb-api-sidecar"
51 changes: 51 additions & 0 deletions desktop/packaging/prune_litellm_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Prune the unused LiteLLM Proxy Server from a frozen build.

`--collect-all litellm` pulls litellm's entire *Proxy Server* (~44 MB: a
FastAPI gateway with an admin UI, swagger assets and OpenAPI snapshots), but
OpenKB only uses litellm as a client. `import litellm` loads a handful of small
proxy submodules; none of the static/data assets below are reached by any
client code path (verified: not in sys.modules after `import litellm` plus a
completion call). Deleting them from the frozen tree is safe; the sidecar smoke
test (`GET /api/v1/kbs` returns 200) confirms the client path stays intact.

Usage: python prune_litellm_proxy.py <path-to-frozen-app-dir>
The frozen app dir is PyInstaller's onedir output (contains `_internal/`).
"""

from __future__ import annotations

import sys
from pathlib import Path

# Static/data dead weight — never imported by a litellm *client*.
_PRUNE_DIRS = ("_experimental", "swagger")
_PRUNE_GLOBS = ("*.jpg", "*.json", "*.yaml", "*.txt", "README.md")


def _dir_size_mb(path: Path) -> int:
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) // (1024 * 1024)


def prune(app_dir: Path) -> None:
proxy = app_dir / "_internal" / "litellm" / "proxy"
if not proxy.is_dir():
print(f"prune_litellm_proxy: no proxy dir at {proxy}; nothing to do")
return
before = _dir_size_mb(proxy)
import shutil

for name in _PRUNE_DIRS:
target = proxy / name
if target.exists():
shutil.rmtree(target)
for pattern in _PRUNE_GLOBS:
for f in proxy.glob(pattern):
f.unlink()
after = _dir_size_mb(proxy)
print(f"prune_litellm_proxy: {before}M -> {after}M")


if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("usage: python prune_litellm_proxy.py <frozen-app-dir>")
prune(Path(sys.argv[1]))
39 changes: 39 additions & 0 deletions desktop/packaging/pyi_rthook_magika.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""PyInstaller runtime hook: stub `magika` so markitdown runs without onnxruntime.

markitdown hard-depends on magika, which loads an ONNX model via onnxruntime
(tens of MB) purely to sniff a file's type from its content. OpenKB only ever
converts real files with correct extensions, so markitdown's extension/mimetype
based stream-info guessing is sufficient and magika's content sniffing is
redundant. This hook installs a lightweight in-memory `magika` module before
markitdown imports it; `identify_stream` returns a non-"ok" status, which makes
markitdown fall back to the extension guess. Paired with
`--exclude-module magika --exclude-module onnxruntime` at build time, this drops
onnxruntime (and magika's model) from the packaged sidecar entirely.
"""

import sys
import types


class _Result:
# Any status other than "ok" makes markitdown use its extension-based guess.
status = "stub"


class Magika:
def __init__(self, *args, **kwargs):
pass

def identify_stream(self, *args, **kwargs):
return _Result()

def identify_bytes(self, *args, **kwargs):
return _Result()

def identify_path(self, *args, **kwargs):
return _Result()


_stub = types.ModuleType("magika")
_stub.Magika = Magika
sys.modules.setdefault("magika", _stub)
11 changes: 11 additions & 0 deletions desktop/packaging/sidecar_entry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Frozen entry point for the OpenKB API sidecar.

Mirrors the `openkb-api = openkb.api:main` console script. The Tauri shell
spawns the frozen binary as `openkb-api-sidecar --host 127.0.0.1 --port <PORT>`;
argparse in `openkb.api.main` reads those args from sys.argv unchanged.
"""

from openkb.api import main

if __name__ == "__main__":
main()
Loading
Loading