DEV Community

Theo Slater
Theo Slater

Posted on

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 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.

Top comments (0)