LocalUI is a Wails v3 desktop AI application. It is Windows-first, with cross-platform support where the Wails target and native APIs allow it.
main.gois the Go composition root. It opens the SQLite database, creates the chat and provider Wails services, embedsfrontend/dist, and creates the application window.frontend/is a React 18 + TypeScript + Vite application using Tailwind v4, shadcn/ui, Base UI, Lucide, Motion, Zustand, and TanStack Virtual.internal/database/owns SQLite persistence and migrations.internal/chat/andinternal/providers/are narrow Wails service facades. The provider service also owns OS keychain access for API keys.- AI generation currently runs in
frontend/src/agent/through the Vercel AI SDK (aiand@ai-sdk/openai-compatible). There is no active Go generation path. OpenAI Agents SDK, MCP, and Zod are future-compatible boundaries, not current runtime dependencies; add them in the TypeScript agent layer if and when they are introduced.
The durable-data split is intentional:
- Chats and messages are stored in SQLite through Wails.
- Provider records are stored in SQLite; provider API keys are stored in the OS keychain and must never be put in SQLite, Zustand persistence, or logs.
- User preferences and model selection are persisted by the Zustand
persistmiddleware in browser storage. - Streaming state, request controllers, dialogs, input, and other transient interaction state stay in memory.
| Location | Owns |
|---|---|
frontend/src/app/ |
Application composition and shell layout. |
frontend/src/features/chat/ |
Chat workspace, composer, messages, Markdown, reasoning, and chat-specific interactions. |
frontend/src/features/providers/ |
Provider lifecycle, provider setup, API-key workflow, and provider presets. |
frontend/src/features/settings/ |
Settings UI and model selection UI. |
frontend/src/components/ui/ |
Reusable shadcn/Base UI primitives and their project styling. |
frontend/src/components/shared/ |
Reusable application pieces that are not shadcn primitives, such as the virtual message list, confirmation dialog, and theme toggle. |
frontend/src/stores/ |
Small Zustand domain stores for shared client state. |
frontend/src/services/ |
Thin Wails adapters, provider/keychain adapters, model discovery, and notifications. |
frontend/src/agent/ |
Provider client construction, model-message conversion, system instructions, streaming, and title generation. |
frontend/src/lib/ |
Pure utilities and performance helpers; no React UI or application state. |
frontend/src/hooks/ |
Reusable browser/platform lifecycle hooks. |
frontend/bindings/ |
Generated Wails TypeScript bindings. Never edit by hand. |
internal/database/ |
SQLite connection, schema migrations, models, and persistence operations. |
internal/chat/ |
Chat/message Wails API surface delegating to internal/database. |
internal/providers/ |
Provider Wails API surface and keychain coordination. |
Prefer feature/domain ownership over a flat folder structure. Keep modules focused and use roughly 250 lines as a review signal, not a mechanical limit; split when there is a real responsibility boundary. Do not create a generic abstraction used once just to reduce line count.
frontend/src/main.tsxmountsAppand the globalToaster, and importsfrontend/src/styles/style.css.app/App.tsxcomposes bootstrap, domain hooks, layout, and feature UI. It owns only app-level composition and transient dialog state. Keep provider lifecycle orchestration infeatures/providers/hooks/use-provider-sync.ts, not inApp.tsx.ChatViewportintentionally owns the message/input/loading subscriptions.Appreads shell state such as chats, selected chat, and generation badges; do not make the whole app subscribe tomessagesduring a stream.- Feature components own rendering, input, local interaction state, and feature-specific composition. They do not own SQLite, keychain access, provider orchestration, tool execution, indexing, or long-running work.
- Keep business logic in stores, services, the agent layer, or Go where it
belongs. Do not use
useEffectas a substitute for derived state.
- Use Zustand only for shared state that genuinely crosses component
boundaries. Keep selectors narrow and stable, for example
useChatStore((s) => s.messages), never a broads => ssubscription. chat-store.tsowns chat selection, persisted chat actions, input state, active request cancellation, stream buffering, and the bridge between the chat UI andagent/text-generation.ts.settings-store.tsowns user preferences, provider configuration presented to the UI, and selected/default model settings. Itspersistmiddleware is the source for local preferences; do not duplicate those preferences in another store or component state.use-provider-sync.tssynchronizes backend providers into settings and chat model selection. Provider saves must go through its orderedenqueueProviderSavepath. Do not bypass that queue with direct backend writes.- Component-local state is preferred for open/closed state, search text, animation state, copied state, and other ephemeral concerns.
- Production components, stores, and features call native functionality only
through
frontend/src/services/.services/chat.tsandservices/providers.tsare the boundary around generated Wails bindings. - Do not import
frontend/bindings/directly from production components, stores, features, or the agent. Tests may mock generated bindings when that is the narrowest way to test a service/store boundary. services/models.tsowns provider model discovery. It performs client-side HTTP discovery for Ollama and OpenAI-compatible providers and uses known model lists for the currently supported Anthropic and Google entries. Keep network work cancellable where the browser API permits, validate provider URLs before requesting them, and do not leak API keys into UI state or logs.services/notifications.tsis the shared user-facing notification boundary. Convert failures into useful user-facing messages at feature boundaries; do not silently swallow errors.- Generated binding models are the source of truth for persisted Wails
payloads. A frontend-only type such as
TextProviderorChatMessagemay intentionally add presentation/selection fields, but convert it at the service or feature boundary rather than inventing competing persisted models. Streaming flags and other UI state must not leak into Go models. - After changing a Go service API, run
wails3 generate bindingsand review the generated diff. Never hand-edit generated binding files.
frontend/src/agent/text-generation.tsowns OpenAI-compatible client creation, model-message conversion, system instructions, streaming text and reasoning events, and generated chat titles.chat-store.tsowns the request lifecycle: provider readiness checks, loading state, one abort controller per active chat, scheduler-driven UI updates, persistence of completed messages, and cleanup on abort, deletion, or chat switch.- Keep model generation out of Go unless a deliberately documented architectural decision adds a new backend capability. Do not create a second streaming path accidentally.
- The conversation model must remain extensible. The persisted message shape currently contains role/content/reasoning, while the view model adds streaming/error state. Future reasoning, tool call/result, command, file-edit, diff, approval, error, attachment, and artifact events should be represented as typed structured items rather than forcing every event into a Markdown string. Do not implement all of those event types speculatively.
- Keep
main.goas composition only. Put domain behavior in the relevantinternal/package and keep Wails methods narrow. - Keep SQL and schema knowledge in
internal/database/; do not scatter raw SQL through unrelated services. The current database layer exposes focused operations for chats, messages, and providers; introduce a separate repository/orchestration layer only when complexity justifies it. internal/database.Opencreates the per-user database directory with restricted permissions, enables foreign keys, WAL, and a busy timeout, and limits the connection pool to one connection. Preserve those properties unless measured requirements change them.- The current schema version is 3. Migrations must be sequential, atomic where
practical, and covered by database tests. Bump
schemaVersionfor schema changes and update migration tests. - Trim and validate boundary inputs. Wrap errors with operation context using
%w; preserve useful causes without exposing raw internal details in the UI. - Long-running or externally blocking backend work must accept/derive
cancellation, respect it, and clean up resources. Do not add
context.Contextto trivial synchronous SQLite CRUD merely for appearance.
This project uses shadcn/ui. The canonical configuration is
frontend/components.json: base-nova style, Base UI primitives, Tailwind v4,
CSS variables, Lucide icons, and the @/components/ui alias.
-
Reuse components from
frontend/src/components/ui/(imported as@/components/ui/...) before writing a new control. This applies to buttons, inputs, dialogs, menus, tooltips, toggles, cards, separators, and similar UI. -
When a needed primitive is missing, use the shadcn CLI from
frontend/:npm exec shadcn -- add <component>
npx shadcn@latest add <component>is also acceptable when intentionally updating the CLI version. Review the generated code, adapt it to the existing Base UI/Tailwind conventions, run formatting, and keep the result insrc/components/ui/. -
Do not add a second UI kit, manually paste an unrelated component implementation, or put feature-specific composition in
components/ui. Put domain composition underfeatures/<domain>/componentsand build it from the shared shadcn primitives. -
Keep
components.json's CSS path aligned with the active global stylesheetsrc/styles/style.css; run the CLI withfrontend/as its working directory. -
Preserve accessible names, keyboard behavior, focus states, and the semantics supplied by Base UI when customizing generated components.
These are load-bearing behaviors. Change them only with measured evidence and corresponding regression coverage.
- Dirty-driven stream buffering:
lib/stream-scheduler.tscoalesces chunks and flushes at about 30 Hz (32 ms) through at most one timeout and onerequestAnimationFrame. Never issue a Zustand update per token. Calldispose()on completion, abort, and teardown; there must be no idle stream timer afterward. - Stable completed messages: the stream replaces only the active message
object. Keep completed message references stable, keep
MessageBubblememoized, and do not recreate the entire message array/object graph on each chunk. - Subscription isolation: the shell/sidebar/settings subscribe only to the
slices they render.
ChatViewportowns message-stream subscriptions. The fullAppmust not rerender for every streamed chunk. - Virtualized conversations:
VirtualMessageListuses@tanstack/react-virtual, estimated row sizes, stable item keys, absolute positioning, and rAF-throttled scroll/resize behavior. Preserve the chat-identity remount key so measurement state cannot leak across chats. - Bounded Markdown streaming:
StreamingMarkdownuses the fence-awarefindStableCutand a tail of at mostTAIL_CHARS(currently 900) when a safe newline boundary exists. Large/unsafe streams use the plain-text or deferred path defined bytechnical-content.tsx. Never parse the full growing message on every chunk; final completed output must use the canonical non-streaming render. - Bounded text animation: per-word streaming animation is limited by the
current
MAX_ANIMATED_WORDSandLARGE_TEXT_THRESHOLDbudgets. Keep opacity/transform effects and any small blur bounded to the streaming tail; never apply them to an unbounded message list or thousands of nodes. Do not add masswill-changeor massbackdrop-filter. - Scroll and model-list work: auto-scroll and virtualized model-list updates are frame-bounded. Model discovery occurs only when the selector is open and is debounced. Preserve reduced-motion behavior.
- Heavy work: keep parsing and other CPU-heavy work bounded/deferred. Move genuinely expensive new work to Go or a worker when it cannot meet the 60 FPS interaction budget.
If streaming, virtualization, or Markdown code changes, run and update the
relevant tests: stream-scheduler.test.ts, long-session.test.ts,
isolation.test.ts, technical-content.test.ts, and
virtual-message-list.test.tsx.
- Use the global tokens in
frontend/src/styles/style.cssinstead of scattering durations: micro80ms, quick150ms, fast250ms, panel350ms; spring easingcubic-bezier(0.22, 1, 0.36, 1); stream gap60ms. - Prefer compositor-friendly
transformandopacity. Width/height transitions belong only at an intentional layout boundary such as the existing resize transition. Do not animate layout on every message or add mass blur/layer promotion. - Respect the global
prefers-reduced-motionrules and the app's reduce-transparency setting. Keep motion subtle and functional. - Keep platform-specific behavior in
lib/platformorservices/platformwith platform submodules. Do not branch on platform throughout feature components or duplicate the entire frontend. - Keep local/sandboxed operations visibly distinct. Destructive or privileged agent actions require an explicit approval/policy path; never execute them implicitly or hide the authority boundary.
Before changing architecture or adding a dependency:
- Read this file and trace the existing owner and data flow.
- Search for an existing service, store, hook, UI primitive, or helper before creating another one.
- Keep the change within the responsible layer and avoid unrelated rewrites.
- Validate untrusted provider/network/backend data at its boundary with explicit runtime checks (use Zod when it is part of the relevant boundary).
- Preserve cancellation, error handling, accessibility, security, and data deletion/rollback behavior.
For frontend or backend code changes, run the smallest relevant proof first. For a normal full change, run from the repository root:
npm --prefix frontend run format
npm --prefix frontend run lint
npm --prefix frontend run typecheck
npm --prefix frontend run typecheck:test
npm --prefix frontend run build
npm --prefix frontend run test
go vet ./...
go test ./...For a documentation-only change, inspect the final diff and verify referenced paths/commands instead of running the full application suite. Report tests not run and any unresolved risk. Do not claim a check passed unless it was run.