Building PolyUI: a local-first AI chat client with Tauri and Rust
I've spent the past several months building PolyUI, a desktop app for chatting with local LLMs through Ollama (it also works with OpenAI-compatible APIs). This post is a quick tour of how it's put together and why I made
I've spent the past several months building PolyUI, a desktop app for chatting with local LLMs through Ollama (it also works with OpenAI-compatible APIs). This post is a quick tour of how it's put together and why I made the choices I did.
Why another chat UI?
Most local-LLM front ends lean on Docker, a Python environment, or both. That's fine for a lot of developers, but it's a lot of friction to hand to a less technical friend who just wants to try running a model locally. PolyUI is a single installer: no containers, no venvs, just an app.
Stack
PolyUI is a Tauri v2 app: a React/TypeScript frontend bundled with a Rust backend, with SQLite (via tauri-plugin-sql) as the persistence layer.
Frontend structure:
- Zustand stores, one per domain (auth, chat, models, settings, folders, theme). Stores don't import each other directly — cross-store effects go through a coordinator module so the dependency graph stays flat and testable.
- Self-contained feature modules (chat, auth, sidebar, models, ollama, settings, memory, providers, folders, web-search, etc).
- A lib layer with no UI: an EventBus interface with a Tauri implementation, a pure token-accumulator that batches streamed text via requestAnimationFrame, and a repository interface for conversations with both a SQLite and an in-memory implementation.
Backend structure:
- Thin Tauri command adapters, with the real logic living in dedicated modules — a tool-calling loop that streams until completion, a StreamEmitter trait with a real Tauri implementation and a test-spy implementation, and separate modules for auth, memory, providers, and model management.
Data flow
Rust emits typed Tauri events (chat-chunk, chat-thinking, web-search-event). The frontend's event bus subscribes to them, a stream accumulator batches the incoming tokens via requestAnimationFrame instead of re-rendering on every chunk, and React hooks consume the accumulator. Multi-model streams each get their own request_id so several models can stream side-by-side without stepping on each other.
Testing
The trait-based seams (StreamEmitter, the repository interface) mean the streaming and persistence logic can be unit tested without spinning up an actual Tauri window — swap in the test spy or the in-memory repository and assert against pure logic.
What it does today
- Multi-model conversations with real-time, side-by-side streaming
- Full Markdown + LaTeX (KaTeX) rendering
- Installing Ollama models from inside the app
- Guest mode for temporary chats that never touch disk
- A handful of built-in system-prompt personas, or a custom one
- Everything stays on-device; nothing leaves your machine without explicit action
It's MIT licensed. Repo's here: https://github.com/monolabsdev/poly-ui — stars, issues, and PRs are all welcome, and I'd love to hear how the architecture holds up if anyone else is building something similar on Tauri.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.