← Corpus / conversational-ui-and-native-shells / profile
kaas
- Path
- profiles/Profile__Kaas.md
Kaas — Profile
A profile of Kaas as it lives in this study (studies/conversational-ui-and-native-shells/kaas/). Cites pinned paths so you can jump to source rather than trust paraphrase. LICENSE:1 confirms MIT (Copyright 2024-present Frank Zhang); src-tauri/Cargo.toml:3-9 confirms the Rust package name kaas v1.0.9, edition 2021, license = "MIT". Read alongside Profile__Openagent.md (Tauri + SvelteKit, MCP + Skills) and Profile__Dive.md (Tauri + Electron dual-shell) — Kaas is the study’s clearest example of local persistence and provider abstraction living entirely in the Rust backend, with the React frontend reduced to a thin, typed IPC client plus local UI/query-cache state.
TL;DR
The README states its own scope plainly:
Kaas is a ChatGPT client designed to serve multiple platforms. Built using Tauri and React, this client places significant emphasis on data privacy and security. It ensures this through local data storage practices… Your credentials and chat data are never sent to our servers. They are stored locally and securely on your device.
Mechanically: all persistence and all provider dispatch happen in Rust; React never touches a database or an LLM SDK directly. Repository (src-tauri/src/services/db.rs:28-30) wraps a single sea_orm::DatabaseConnection against a SQLite file at <app_data_dir>/database.sqlite (src-tauri/src/init.rs:120-122, get_sqlite_path), created and migrated via sea-orm-migration (Migrator::up, db.rs:33-42) with six schema migrations plus two seed migrations under src-tauri/migration/src/ (e.g. m20250214_000001_messages_add_reasoning_fields.rs — the newest, adding reasoning-model support). Repository is registered once as Tauri managed state (app.handle().manage(repo), init.rs:60) and every #[tauri::command] in src-tauri/src/commands.rs receives it via State<'_, Repository>.
Provider dispatch is a closed Rust enum, not a runtime plugin system: LLMClient (src-tauri/src/services/llm/client.rs:21-30) is OpenAIClient | AzureClient | ClaudeClient | OllamaClient | OpenrouterClient | DeepseekClient | XaiClient | GoogleClient, each wrapping an async_openai::Client<C> with a provider-specific Config impl (src-tauri/src/services/llm/providers/{claude,deepseek,google,ollama,openrouter,xai}/config.rs). LLMClient::new() (client.rs:34-99) matches on Providers from the model’s stored config, deserializes a Raw*Config (src-tauri/src/services/llm/types.rs), and converts it Into<ProviderConfig> — so Claude and Ollama ride the same async-openai request/response plumbing as OpenAI, via a fork (async-openai = { git = "...0xfrankz/async-openai.git", branch = "google-compatibility" }, Cargo.toml:16) rather than separate SDKs per vendor.
Streaming crosses the Tauri IPC boundary as a window event bus with string sentinels, not Tauri’s typed Channel<T> API: emit_stream_start/data/done/error/stopped (commands.rs:687-736) call window.emit(tag, ...) with payloads "[[START]]", "[[DONE]]", "[[ERROR]]<msg>", "[[STOPPED]]", or a JSON-stringified BotReply. The frontend’s useReplyListener(tag) hook (src/lib/hooks.tsx:680-778) does listen<string>(tag, ...) and pattern-matches those exact string prefixes (STREAM_START, STREAM_DONE, STREAM_STOPPED, STREAM_ERROR constants) to reassemble the incremental reply, concatenating message/reasoning token deltas by hand. Cancellation is a second event, stop-bot, bound with window.listen("stop-bot", ...) inside the same async command handler (commands.rs:594,673) — the backend registers a listener for its own cancellation signal rather than using a cancellation token passed as an argument.
If you want one sentence: Kaas keeps every LLM credential, every conversation, and every provider-specific request-shaping decision inside the Rust process — behind a SeaORM/SQLite repository and a closed LLMClient enum built on a forked async-openai — and exposes only typed #[tauri::command] functions plus a raw window.emit/listen string-sentinel event bus to a React frontend that owns nothing but Zustand UI state and a TanStack Query cache.
Local persistence — SeaORM over SQLite, one file, six tables
- One connection, one file.
get_sqlite_path()(src-tauri/src/init.rs:118-122) resolves to<app_data_dir>/database.sqlite;Builder::build()(db.rs:929-949) callsSqlite::create_databaseif missing, thenDatabase::connect("sqlite:{url}")— a singlesea_orm::DatabaseConnection, no pooling knobs exposed, no WAL mode configured (contrast with a harness that tunes journal mode explicitly). - Migrations are code, not raw SQL files.
src-tauri/migration/src/holds one Rust file per migration (m20240101_000001_create_models.rsthroughm20250214_000001_messages_add_reasoning_fields.rs), registered inMigratorand run viaMigrator::up(&self.connection, None)(db.rs:33-42) on every app start — idempotent, no explicit schema-version gate visible indb.rsitself (SeaORM’s migrator tracks applied migrations internally). - Soft delete is the default; hard delete is explicit and separate.
delete_model,delete_conversation,delete_promptall set adeleted_attimestamp and update (db.rs:106-123, 314-340, 904-921); message deletion instead has two named paths,hard_delete_messages(bulk, by conversation) andhard_delete_message(single), both doing a real SQLDELETE(db.rs:828-856) — the only entity in this schema with a true-delete path, presumably because edited/regenerated bot turns shouldn’t accumulate soft-deleted rows forever. - Provider-shaped options live as a serialized JSON blob per conversation, validated by round-tripping through a typed struct.
conversations::Model.options: Option<String>(entity/src/entities/conversations.rs:21) holds one ofAzureOptions | OpenAIOptions | ClaudeOptions | OllamaOptions(same file, lines 117-394) depending on the linked model’s provider.update_conversation_options()(db.rs:402-498) deserializes the incoming JSON into the provider-specific struct and re-serializes it before writing —serde_json::from_str::<ClaudeOptions>(...)thento_string(&claude_options)— so malformed or extra fields from the frontend are silently normalized away rather than stored verbatim. Every conversation-creation path (create_conversation,create_conversation_with_content,update_conversation_model,db.rs:176-286, 537-578) independently re-runs this same four-way provider match to set a fresh default-options blob, rather than centralizing it in one helper — the samematch model.provider.into() { Azure => ..., Claude => ..., Ollama => ..., _ => OpenAIOptions }block is duplicated four times acrossdb.rs. - Context assembly for a chat turn is a backend SQL query, not a frontend slice of cached messages.
get_last_messages(conversation_id, n, before_message_id)(db.rs:628-666) uses SeaORM’scursor_by(Column::Id).last(n)to fetch the last N rows directly from SQLite, thenload_many(contents::Entity, ...)to eager-load each message’s content rows —commands.rs:405-448(call_bot) computesnfrom a per-conversationcontextLengthoption (falling back to a globalSETTING_MODELS_CONTEXT_LENGTHsetting) and prepends the system message if one exists. The frontend never assembles or trims context; it only tells the backend which conversation and where to cut (before_message_id, used for regenerate-from-here). - A separate content table, not inline message text.
messagesandcontentsare distinct entities (create_message,db.rs:671-715, runs inside atransaction::<_, MessageDTO, DbErr>that inserts the message row, then bulk-inserts itscontents::ActiveModelrows, then bumpsconversations.last_message_at) — this is what lets a single message hold multiple content parts (text + image, perContentTypeinentity/src/entities/contents.rs) atomically.
The provider abstraction — one enum, one HTTP client shape, per-provider Config impls
LLMClient (client.rs:21-30) is a closed 8-variant enum (OpenAI, Azure, Claude, Ollama, Openrouter, Deepseek, Xai, Google), each variant tupling an async_openai::Client<C> with Option<String> (the resolved model name — None for Azure, which addresses the model via deployment_id instead, types.rs:17-25). LLMClient::new() (client.rs:34-99) is the single construction chokepoint:
- Builds one shared
reqwest::Clientper call viabuild_http_client(proxy_setting)(src-tauri/src/services/llm/utils.rs, imported atclient.rs:16) — proxy settings are looked up from thesettingstable (SETTING_NETWORK_PROXY,commands.rs:87-97) and threaded through to every provider uniformly. - Matches
config.provider.as_str().into(): Providersand deserializes aRaw*Configstruct (types.rs) — e.g.RawClaudeConfig { api_key, model, api_version, endpoint }(types.rs:53-58) — then converts itInto<ClaudeConfig>(types.rs:60-71), which itself implementsasync_openai::config::Configso the rest of the request/response/streaming machinery is fully shared with OpenAI’s own code path. - Two providers ride on top of OpenAI’s config type directly rather than a bespoke one:
OpenrouterreusesRawOpenAIConfig/OpenAIConfigbut overrides the base URL (with_api_base(DEFAULT_OPENROUTER_API_BASE),client.rs:64-71);CUSTOM(any OpenAI-compatible endpoint) shares the exact same match arm asOpenAI(client.rs:43-49).
chat() / chat_stream() (client.rs:147-213) both funnel through generic helpers (execute_chat_request / execute_chat_request_stream, client.rs:101-145) parameterized over an F: FnOnce(&Client<C>, ..., String) -> Result<ChatRequestExecutor, String> — a per-provider constructor function (ChatRequestExecutor::claude, ::ollama, etc., defined in src-tauri/src/services/llm/chat.rs) — so adding a ninth provider means adding one enum variant, one Raw*Config/*Config pair, and one ChatRequestExecutor::<name> constructor, not touching the dispatch logic itself. models() (client.rs:215-250) is the one place providers genuinely diverge in capability: Azure explicitly returns Err("List models API is not supported by Azure") rather than a variant of the enum being unreachable.
The Tauri↔React IPC boundary — typed commands for CRUD, raw events for streaming
Two distinct transports coexist, chosen per call shape:
- Request/response CRUD goes through
#[tauri::command]+invoke. Every backend mutation (create_model,list_conversations,upsert_setting, etc. —commands.rs:38-370) is a plain async function returningCommandResult<T> = Result<T, CommandError>(commands.rs:36,CommandErrordefined insrc-tauri/src/errors.rsas a taggedApiError | DbErrorenum). The frontend wraps each in a typedinvoke<T>('command_name', {...})call insrc/lib/commands.ts(e.g.invokeCreateModel,commands.ts:27-35) and layers TanStack Query hooks on top (useListModelsQuery,useCreateConversationMutation, etc. —src/lib/hooks.tsx:106-566) for caching and optimistic updates (useSubjectUpdater’sonMutatesnapshot/rollback,hooks.tsx:498-529). - Streaming chat replies go through
window.emit/listen, keyed by a caller-suppliedtagstring, not a TauriChannel.call_bot(commands.rs:~370-460) takes atag: Stringargument from the frontend, and every downstreamemit_stream_*helper (commands.rs:687-736) emits on that exact event name. The frontend’sinvokeCallBotpasses the same tag throughinvoke, then a separateuseReplyListener(tag)hook (hooks.tsx:680-778) opens alisten<string>(tag, ...)subscription scoped to that one tag — meaning the “channel” is really just an app-wide event namespace disambiguated by a string the frontend invents per in-flight request. Payloads are hand-encoded sentinels ("[[START]]","[[DONE]]","[[STOPPED]]","[[ERROR]]<message>") or raw JSON text for data frames — there is noserde-typed event payload enum shared between Rust and TS; both sides independently agree on the string format (STREAM_STARTetc. constants insrc/lib/constants.ts, matched against literal strings written incommands.rs). - Cancellation is itself a third event, not a return value or an
AbortSignal. The backend, mid-stream, binds a listener for a fixed event name"stop-bot"(commands.rs:594, 673) scoped to the lifetime of that one request’s async task, and unbinds it (window_clone.unlisten(event_handle)) once the stream ends — so a straystop-botemit after a request already finished is a no-op by construction, but two concurrent in-flight requests would both react to the same globalstop-botevent (no per-request cancellation token is threaded through IPC; thetagnamespacing only covers reply data, not the cancel signal). - Capabilities are declared, not inferred.
src-tauri/capabilities/desktop.jsonenumerates the exact permission set —fs:allow-read-file,fs:allow-write-file, anfs:scopeallowlist restricted to$APPDATA/*and$RESOURCE/*,clipboard-manager:*,shell:allow-open,dialog:allow-save,updater:default— matching the README’s claim that “we deliberately limit the privileges of the client to the minimum necessary.”
Provider-scoped options as first-class, schema-validated state
Rather than one generic {temperature, maxTokens, ...} options bag, each provider has its own Rust struct with its own defaults (entity/src/entities/conversations.rs:117-394): AzureOptions, OpenAIOptions (adds reasoning_effort for o-series models), ClaudeOptions (defaults temperature: 0.5, unlike every other provider’s 1.0), OllamaOptions (uses num_ctx/num_predict instead of max_tokens), DeepseekOptions, XaiOptions, GoogleOptions — all implementing a shared marker trait Options (conversations.rs:115) that gives no shared behavior beyond type grouping. On the frontend, ConversationOptionsDialog.tsx and src/lib/schemas.ts mirror this per-provider shape so the settings form rendered for a conversation depends on which provider its linked model uses. Switching a conversation’s model is a backend command (update_conversation_model, db.rs:537-578) that re-derives a fresh default options blob for the new provider — old provider-specific settings (e.g. a tuned Claude top_p) do not carry over across a provider switch, by design of the match-and-overwrite pattern.
What’s inside this submodule
| Path | What’s there |
|---|---|
src-tauri/src/services/db.rs | Repository — every SQL operation, soft/hard delete split, provider-options re-validation, transactional message+content writes |
src-tauri/src/services/llm/client.rs | LLMClient enum — the provider dispatch chokepoint, new()/chat()/chat_stream()/models() |
src-tauri/src/services/llm/types.rs | Raw*Config structs — camelCase JSON-to-async_openai::Config conversion per provider |
src-tauri/src/services/llm/chat.rs | ChatRequestExecutor, BotReply/BotReplyStream, GlobalSettings |
src-tauri/src/services/llm/providers/*/config.rs | Per-provider Config impls (Claude, Deepseek, Google, Ollama, Openrouter, Xai) riding async-openai’s trait |
src-tauri/src/commands.rs | Every #[tauri::command]; call_bot/call_bot_stream; the emit_stream_* event-sentinel helpers |
src-tauri/src/init.rs | App bootstrap — app-data-dir resolution, database.sqlite path, migration run, cache dir, locale-derived default setting |
src-tauri/entity/src/entities/conversations.rs | ConversationDTO, GenericOptions, and all seven per-provider *Options structs |
src-tauri/entity/src/entities/{models,messages,contents,prompts,settings}.rs | Remaining SeaORM entities |
src-tauri/migration/src/ | One Rust file per schema migration, run via Migrator::up on every launch |
src-tauri/capabilities/desktop.json | Tauri v2 capability/permission manifest — the declared privilege ceiling |
src/lib/commands.ts | Typed invoke() wrappers, one per backend command |
src/lib/hooks.tsx | TanStack Query hooks per entity; useReplyListener — the streaming-event consumer |
src/lib/store.ts | Zustand useAppStateStore (models + settings cache) and useConfirmationStateStore — all client-only UI state |
src/lib/contexts.ts | React contexts (ConversationsContext, MessageListContext, etc.) layered over the query cache |
src/components/Conversation/, src/components/Conversations/ | Per-conversation chat UI vs. the conversation-list/grid UI — split the same way message-vs-list state is split in hooks |
docs/options.md | User-facing doc enumerating every supported config/option per provider |
If you read three files: src-tauri/src/services/llm/client.rs (the whole provider-abstraction shape lives here), src-tauri/src/services/db.rs (every persistence decision, including the duplicated provider-options-defaulting pattern), and src-tauri/src/commands.rs (the actual IPC surface — both the typed-command half and the raw-event-sentinel streaming half).
Mental model for using it well
- Treat the Rust backend as the only place that knows about providers. The frontend never imports an OpenAI/Anthropic SDK; it sends a
GenericConfig { provider: String, config: String }blob and gets typed DTOs back. Adding a provider means Rust-side work only (LLMClientvariant +Raw*Config+ChatRequestExecutorconstructor); the frontend just needs a new options schema and a provider tag string. – Distinguish the two IPC transports by call shape, not by convention alone. One-shot CRUD →#[tauri::command]/invoke. Anything that streams tokens → an app-widewindow.emit/listenevent keyed by a request-generatedtag, with hand-agreed string sentinels — there’s no compile-time guarantee the Rust and TS sides agree on the payload shape here, unlike the CRUD commands whereserde/TS types line up. - Read
conversations.optionsas “the frozen provider-shaped request parameters for this one conversation,” re-validated (parse-then-reserialize) on every write — don’t expect arbitrary extra JSON keys from the frontend to survive a round trip. - Expect context assembly to happen server-side.
call_botcomputes exactly which prior messages go to the model using a stored per-conversation or global context-length setting; the frontend’s job is only to pick abefore_message_idfor “regenerate from here” semantics. - Watch the soft-vs-hard delete split. Models/conversations/prompts are soft-deleted (
deleted_at) and simply filtered out of list queries; messages are the one entity with trueDELETEpaths, because edit/regenerate flows would otherwise leave orphaned superseded turns.
When NOT to reach for this
- You want an MCP-client or tool-calling architecture. There is no
mcpstring, no tool/function-calling abstraction, and no plugin system anywhere insrc-tauri/src/— Kaas is a pure conversational client against chat-completion endpoints, not an agent harness.openagent(Tauri + SvelteKit, MCP + Skills) is the sibling entry for that shape. - You need a cross-shell (Electron-and-Tauri) reference. Kaas ships one shell only; if the study question is specifically about dual-shell strategy,
dive(Tauri + Electron dual) is the more relevant read. - You want typed, compiler-checked streaming payloads across the IPC boundary. The
window.emit/string-sentinel pattern ("[[DONE]]", JSON-as-string data frames) works but has no shared schema; a harness or app needing stronger guarantees here should look at Tauri’sChannel<T>API instead, which Kaas does not use. - You want per-request cancellation.
stop-botis a single global event name, not scoped per in-flighttag— fine for a single-window single-active-stream chat client, insufficient if multiple concurrent streams need independent cancellation. - You need SQLite tuned for concurrent access (WAL mode, connection pooling).
Repositoryopens oneDatabaseConnectionwith no journal-mode configuration visible indb.rs— adequate for a single-process desktop app, not a pattern to copy for anything with multiple readers/writers.
How this compares to the rest of the study
| Axis | Kaas | openagent | dive |
|---|---|---|---|
| Shell | Tauri (Rust) + React | Tauri (Rust) + SvelteKit | Tauri and Electron (dual shell) |
| Persistence | SQLite via SeaORM, single DatabaseConnection, code-based migrations (src-tauri/migration/) | (see Profile__Openagent.md) | (see Profile__Dive.md) |
| Provider abstraction | Closed Rust enum (LLMClient, 8 variants) wrapping a forked async-openai per provider Config impl | — | — |
| Tool-calling / MCP | None — pure chat-completion client | MCP + Skills native | — |
| Streaming IPC transport | window.emit/listen, string-sentinel protocol ("[[START]]"/"[[DONE]]"/"[[ERROR]]..."), keyed by a frontend-generated tag | — | — |
| Cancellation | Global stop-bot window event, bound/unbound per in-flight request | — | — |
| Conversation options model | Per-provider Rust structs (AzureOptions, ClaudeOptions, etc.) stored as re-validated JSON in conversations.options | — | — |
| Frontend state ownership | Zustand (UI-only: models cache, settings, confirmation dialogs) + TanStack Query (server-cache) — no client ever touches SQL or an LLM SDK | — | — |
| Best fit | Local-first, credential-private multi-provider chat client where the backend is the trust boundary and the frontend is a thin typed client | Native shell wanting MCP/Skills-driven extensibility | Cross-shell (Tauri/Electron) comparison work |
Kaas’s load-bearing contribution to this study is the provider abstraction living entirely behind one Rust enum over a forked shared HTTP client library, paired with a persistence layer that treats “provider-specific options” as validated, re-serialized server state rather than opaque frontend JSON — a clean answer to “how do you let a user swap providers/models mid-app without smearing provider knowledge into the UI layer.”
One-line summary
Kaas is a Tauri + React desktop chat client where the Rust backend owns everything that matters — a single SeaORM/SQLite
Repositorywith soft-delete-by-default and code-based migrations, a closedLLMClientenum that dispatches eight providers through one forkedasync-openaiHTTP client and per-providerConfig/OptionsRust structs re-validated on every write — while the React frontend is reduced to typedinvoke()CRUD calls plus awindow.emit/listenstring-sentinel event bus for streaming tokens, cancellable only via a single globalstop-botevent, with Zustand holding pure UI state and TanStack Query holding the server-mirrored cache.