# LocalMind [![CI](https://github.com/evandrodevbr/LocalMind/actions/workflows/ci.yml/badge.svg)](https://github.com/evandrodevbr/LocalMind/actions/workflows/ci.yml) **Chat client for local LLMs served by LM Studio. React Native + Expo app (Android, iOS and web) where every message stays on your own network: no cloud, no API key, no telemetry.** ![React Native](https://img.shields.io/badge/React_Native-0.81.5-20232A?logo=react&logoColor=61DAFB) ![Expo](https://img.shields.io/badge/Expo_SDK-54-1C1E24?logo=expo&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white) ![Zustand](https://img.shields.io/badge/Zustand-5-443E38?logo=react&logoColor=white) ![LM Studio](https://img.shields.io/badge/LM_Studio-OpenAI--compatible_API-5E5DF0) ## About Running a model locally in LM Studio is easy; having a decent chat UI for it on the phone is not. LocalMind is that client: it talks straight to the LM Studio local server (the OpenAI-compatible `/v1` endpoints) over your LAN or `localhost`, streams the answer token by token, keeps chat sessions on the device and renders Markdown with code blocks. It is an alpha project. There is no backend, no account and no server to deploy: the app is only the client, and the model runs on your machine. ## How it works ``` Expo app (Android / iOS / web) app/chat, app/settings ......... screens (expo-router, file based) hooks/useLMStudio .............. sendMessage / regenerate / stop, stream callbacks services/lmStudioApi ........... HTTP + SSE client store/useAppStore .............. Zustand + AsyncStorage (sessions, settings, theme) | | GET /v1/models | POST /v1/chat/completions (stream: true -> SSE) v LM Studio local server (default http://localhost:1234) ``` - The connection settings (host, port, optional API key) live in the Zustand store and are persisted with `AsyncStorage`. - `services/lmStudioApi.ts` builds the base URL as `http(s)://:/v1` and streams the response: `XMLHttpRequest` progress events on native platforms, `fetch` + `ReadableStream` on web. - Each request carries a fixed system prompt (`constants/SystemPrompt.ts`); `temperature` is `0.7` and `max_tokens` is `2048`. - Every finished (or stopped) exchange is saved as a chat session (title taken from the first user message), so History can reload it later. Writes to `AsyncStorage` are debounced (400 ms) so streaming does not serialize the whole history on every token. - When the chat opens, the app checks the saved server once and loads its model list; the selected model is remembered between launches. ## Stack | Layer | Choice | |---|---| | Runtime | Expo SDK 54, React Native 0.81.5, React 19.1 | | Routing | expo-router 6 (typed routes experiment on, React Compiler experiment on) | | State | Zustand 5 with `persist` + `@react-native-async-storage/async-storage` | | Animation / gestures | react-native-reanimated 4, react-native-gesture-handler | | Icons | lucide-react-native | | Markdown | `@ronradtke/react-native-markdown-display` | | Streaming | XMLHttpRequest (native) / fetch ReadableStream (web) | | Tooling | TypeScript 5.9 (`strict`), ESLint 9 flat config (`eslint-config-expo`), Jest 29 with `jest-expo` and Testing Library | | Model server | LM Studio local server, OpenAI-compatible `/v1` API | ## Requirements - Node.js `>= 20.19.4` (required by React Native 0.81.5; CI uses Node 22, local runs were done on Node 26.7.0) - npm to install dependencies (the committed `package-lock.json` is the maintained lockfile) - [LM Studio](https://lmstudio.ai) with a model downloaded, the local server started and **CORS enabled** (Local Server tab, default port `1234`) - To run the app: [Expo Go](https://expo.dev/go) on a physical device, an Android/iOS emulator, or a browser for the web target ## Quick start ```bash git clone https://github.com/evandrodevbr/LocalMind.git cd LocalMind npm install npx expo start ``` Then pick a target from the Expo CLI: `w` for web, `a` for Android, `i` for iOS, or scan the QR code with Expo Go. Start the model side first: 1. Open LM Studio, download a model and go to the **Local Server** tab (the `<->` icon). 2. Enable **CORS** and keep the port at `1234` (or note whatever you change it to). 3. Click **Start Server**. Finally, in the app: 1. Open **Settings** (gear icon in the chat header). 2. Fill **Host IP / Address** and **Port Number**. On a physical device this must be your machine's LAN address (`192.168.x.x`), not `localhost`. 3. Tap **Test Connection**. The badge turns to *Connected* and the model list becomes available in the chat header selector. ## Usage | Screen | What it does | |---|---| | Chat (`/chat`) | Conversation list with Markdown rendering, streaming answers, copy code blocks, edit / delete / regenerate a message, stop generation while it streams, model selector in the header, "new chat" button. | | History (`/chat/history`) | Saved sessions, most recent first, with message count and date; tap to reload, trash icon to delete. | | Settings (`/settings`) | Host, port and optional API key, connection test and status badge, light/dark toggle, app info. | ## API The app is a client of the LM Studio OpenAI-compatible API. Endpoints actually used: | Call | Where | Notes | |---|---|---| | `GET /v1/models` | `testConnection()`, `getModels()` | The connection test aborts after 5 s; the model list feeds the header selector. | | `POST /v1/chat/completions` | `chatCompletion()`, `chatCompletionStream()` | Streaming (`stream: true`) is the path used by the chat; SSE lines with `choices[0].delta.content` are appended to the last message. | - Base URL: `http://:/v1` (`http://` is prepended when the host has no `http://` or `https://` scheme). - An `Authorization: Bearer ` header is sent only when an API key is filled in. - `GET /v1/models` responds with the OpenAI shape `{ "data": [ { "id": ... } ] }`. ## Web target The same codebase exports a static web build: ```bash npx expo export --platform web # writes dist/ npx serve dist # or any static server that maps /chat -> chat/index.html ``` The export generates `/`, `/chat`, `/chat/history`, `/settings`, `/_sitemap` and `/+not-found`. Use a static server that resolves extension-less URLs (`npx serve`, nginx `try_files $uri $uri.html $uri/ =404`); point unknown paths at `+not-found.html` for the app's 404 screen. ## Project structure ``` app/ ├── _layout.tsx root stack, theme provider, splash screen ├── index.tsx redirects to /chat ├── +not-found.tsx 404 screen ├── chat/ │ ├── _layout.tsx chat header (model selector, history, new chat, settings), auto-connect │ ├── index.tsx conversation screen, edit/regenerate flow │ └── history.tsx saved sessions └── settings/index.tsx composes the settings cards components/ ├── chat/ ChatInput, MessageList, MessageBubble, MessageContent, CodeBlock, │ MessageActions, ModelSelector, EmptyState, OfflineBanner, TypingIndicator ├── settings/ ServerConnectionCard, AppearanceCard, AboutCard, LabeledInput, SettingsSection └── ui/ ConnectionBadge constants/ Colors, SystemPrompt (system prompt sent to the model) hooks/ useLMStudio (chat flow), useAutoConnect, useThemeColor, useColorScheme services/ lmStudioApi (HTTP/SSE), types store/ useAppStore (Zustand), debouncedStorage (AsyncStorage wrapper) utils/ pure helpers (server form parsing, theme, chat selectors) __tests__/ Jest tests docs/ LM Studio API notes and design reference .agent/ AI development tooling (agents, skills, rules), not app code ``` ## Scripts | Command | What it does | |---|---| | `npm start` | Expo dev server (`npm run web`, `npm run android`, `npm run ios` pick a target) | | `npm run typecheck` | `tsc --noEmit` | | `npm run lint` | `expo lint` | | `npm test` | Jest (`jest-expo`), tests in `__tests__/` | | `npm run build` | `expo export --platform web`, writes `dist/` | The GitHub Actions workflow (`.github/workflows/ci.yml`) runs `typecheck`, `lint`, `test` and `build` on pushes to `main` and on pull requests. ## Tests `npm test` runs 11 suites (120 tests) covering the pure and stateful logic and the main screens: - `lmStudioApi`: base URL building, SSE parsing, connection test and timeout, model list, web (`fetch`) and native (`XMLHttpRequest`) streaming, HTTP and connection errors; - `useAppStore`, `debouncedStorage`: messages, chat sessions, persistence, write debouncing; - `useLMStudio`, `useAutoConnect`: sending, regenerating, stopping, model loading; - `ChatScreen`, `SettingsScreen`, `MessageBubble`, `MessageList`, `ChatInput` (rendered with Testing Library) and `utils`. The native streaming path is exercised against a fake `XMLHttpRequest`, not a real device. ## Verification Last run on Windows 11 (Node 26.7.0, npm): `npm ci`, `npm run typecheck`, `npm run lint` (0 errors, 0 warnings), `npm test` (120 passed) and `npm run build` (6 static routes in `dist/`) all succeed. Not verified: Android and iOS builds (no emulator or device available) and a run against a real LM Studio server. An earlier manual run of the exported web build against a mock of the `/v1` API (headless Chromium) answered a chat message and persisted the session in `localStorage` under `localaiapp-storage`. ## Current state and limitations - Alpha. It was built against models served by LM Studio and has no fallback to any cloud API. - The server is checked once when the chat opens and whenever the model selector is refreshed or Settings tests the connection; there is no periodic reconnect. - The stored model id is not validated against the server's model list after a restart. - Both `package-lock.json` and `pnpm-lock.yaml` are committed; only `package-lock.json` is kept in sync (CI uses `npm ci`). - `app.json` still carries the previous project name (`LocalAiApp` / slug `localaiapp`) and the persisted storage key is `localaiapp-storage`. - No license file. ## Documentation | Document | Content | |---|---| | [`docs/Documentação de Implementaçoes Futuras.md`](docs/Documenta%C3%A7%C3%A3o%20de%20Implementa%C3%A7oes%20Futuras.md) | Notes on the LM Studio API (`/api/v0`, `/v1`, SDKs, CLI) and roadmap ideas (in Portuguese) | | [`docs/evandro-design-system.html`](docs/evandro-design-system.html) | Visual reference of the design system used in the UI | ## License No license file is present in this repository. Without an explicit license the code is "all rights reserved" by default, so adding one (MIT, for example) is a pending decision of the project owner.