Compare commits

2 Commits
63 changed files with 2654 additions and 6462 deletions

No files matched your search

-21
View File
@@ -1,21 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm test
- run: npm run build
+53 -160
View File
@@ -1,190 +1,83 @@
# LocalMind <div align="center">
[![CI](https://github.com/evandrodevbr/LocalMind/actions/workflows/ci.yml/badge.svg)](https://github.com/evandrodevbr/LocalMind/actions/workflows/ci.yml) <img src="assets/readme/hero.svg" width="100%" alt="LocalMind: chat with your LM Studio models from a phone or browser on your own network">
**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 0.81](https://img.shields.io/badge/React_Native-0.81-61DAFB?style=flat-square&logo=react&logoColor=black)
![Expo 54](https://img.shields.io/badge/Expo-54-1C1E24?style=flat-square&logo=expo&logoColor=white)
![TypeScript 5.9](https://img.shields.io/badge/TypeScript-5.9-3178C6?style=flat-square&logo=typescript&logoColor=white)
![Platforms](https://img.shields.io/badge/platforms-Android_%7C_iOS_%7C_Web-555?style=flat-square)
![Status](https://img.shields.io/badge/status-alpha-orange?style=flat-square)
![React Native](https://img.shields.io/badge/React_Native-0.81.5-20232A?logo=react&logoColor=61DAFB) [Quick start](#quick-start) · [Issues](https://git.evandro.dev.br/evandro/LocalMind/issues)
![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 </div>
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. ## What it is
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. LocalMind is a chat client for the models you run in [LM Studio](https://lmstudio.ai). It is an Expo app for Android, iOS and the web that talks to LM Studio's OpenAI-compatible `/v1` API over your LAN or `localhost`. It is only the client: there is no backend, no account and no server to deploy.
## Why use it
- **Your messages stay on your network.** The app only talks to the LM Studio address you set in Settings. There is no cloud API and no fallback to one.
- **Real streaming on every target.** Requests go out with `stream: true` and each SSE `choices[0].delta.content` is appended as it arrives: through `XMLHttpRequest` progress events on native, through `fetch` with a `ReadableStream` on the web. You can stop generation mid-stream.
- **The chat basics are there.** Markdown with copyable code blocks, edit, delete and regenerate, a model selector fed by LM Studio, history saved on the device, and light, dark or system theme.
**When not to use it:** if you want a cloud model, a server other than LM Studio (the app was built against LM Studio only), or a verified phone build. The Android and iOS builds and the native streaming path have not been verified yet.
## How it works ## How it works
``` - Screens are file-based routes (`expo-router`): `app/chat`, `app/chat/history` and `app/settings`. `hooks/useLMStudio` exposes send, regenerate, stop and model fetching.
Expo app (Android / iOS / web) - `services/lmStudioApi` builds `http(s)://<host>:<port>/v1`, calls `GET /v1/models` for the selector and the connection test, and streams `POST /v1/chat/completions`.
app/chat, app/settings ......... screens (expo-router, file based) - `store/useAppStore` (Zustand) persists theme, server settings, chat sessions and the current chat in `AsyncStorage`. A finished exchange becomes a session titled from the first user message.
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)://<host>:<port>/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 ## Quick start
You need Node.js 20.19.4 or newer (required by React Native 0.81; not declared in `package.json`), npm, and LM Studio with at least one model downloaded. To run the app, use [Expo Go](https://expo.dev/go) on a device, an Android or iOS emulator, or a browser.
**1. Start LM Studio's server.** Open the **Local Server** tab (the `<->` icon), load a model, enable **CORS**, keep the port at `1234` (or note yours) and click **Start Server**.
**2. Run the app.**
```bash ```bash
git clone https://github.com/evandrodevbr/LocalMind.git git clone https://git.evandro.dev.br/evandro/LocalMind.git
cd LocalMind cd LocalMind
npm install npm install
npx expo start npm run start # Expo CLI: press w (web), a (Android), i (iOS) or scan the QR code with Expo Go
``` ```
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. `npm run android`, `npm run ios` and `npm run web` open a target directly. Do not run `npm run reset-project`: it is the Expo template script and moves or deletes `app/`, `components/`, `hooks/`, `constants/` and `scripts/`.
Start the model side first: **3. Point the app at LM Studio.** Open **Settings** (gear icon in the chat header), fill **Host IP / Address** and **Port Number**, then tap **Test Connection**. On a physical device the host must be your computer's LAN address (`192.168.x.x`), not `localhost`. The badge turns to *Connected* and the loaded models appear in the header. Until then the chat shows *Server offline*: the app does not connect on launch.
1. Open LM Studio, download a model and go to the **Local Server** tab (the `<->` icon). ## Configuration
2. Enable **CORS** and keep the port at `1234` (or note whatever you change it to).
3. Click **Start Server**.
Finally, in the app: The app reads no environment variables. The server address is a runtime setting stored on the device:
1. Open **Settings** (gear icon in the chat header). | Setting | Default | Notes |
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. | | Host IP / Address | `localhost` | `http://` is prepended when the value does not start with `http` |
| `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. | | Port Number | `1234` | LM Studio's default port |
| API Key (optional) | empty | Sent as `Authorization: Bearer <key>` only when filled in |
- Base URL: `http://<host>:<port>/v1` (`http://` is prepended when the host has no `http://` or `https://` scheme). Everything else lives in code: `temperature: 0.7` and `max_tokens: 2048` in `services/lmStudioApi.ts` (the connection test gives up after 5 s), the fixed system prompt in `constants/SystemPrompt.ts`, and the persisted state under the `localaiapp-storage` key in `store/useAppStore.ts`.
- An `Authorization: Bearer <apiKey>` header is sent only when an API key is filled in.
- `GET /v1/models` responds with the OpenAI shape `{ "data": [ { "id": ... } ] }`.
## Web target ## Project status
The same codebase exports a static web build: Alpha, with no tests and no CI. Known gaps:
```bash - The selected model and the model list are not persisted, so you pick a model again after a restart.
npx expo export --platform web # writes dist/ - `constants/Models.ts` (a Qwen model catalog) and the `react-native-sse` dependency are not used by any code.
npx serve dist # or any static server that maps /chat -> chat/index.html - `app.json` and the storage key still carry the previous project name (`LocalAiApp`, `localaiapp-storage`).
``` - Both `package-lock.json` and `pnpm-lock.yaml` are committed.
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. Before opening a PR, run `npm run lint` (Expo's ESLint config) and `npx tsc --noEmit`. Notes on the LM Studio API, in Portuguese, are in [`docs/`](docs/).
## 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 ## 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. No published license: all rights reserved.
<div align="center">
<sub>Made by <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
</div>
-39
View File
@@ -1,39 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';
import { ChatInput } from '../components/chat/ChatInput';
describe('ChatInput', () => {
it('sends trimmed text, clears the field and disables send while empty', () => {
const onSend = jest.fn();
render(<ChatInput onSend={onSend} />);
expect(screen.getByLabelText('Send message').props.accessibilityState.disabled).toBe(true);
fireEvent.changeText(screen.getByLabelText('Message'), ' hello ');
fireEvent.press(screen.getByLabelText('Send message'));
expect(onSend).toHaveBeenCalledWith('hello');
expect(screen.getByLabelText('Message').props.value).toBe('');
});
it('shows a stop button instead of send while loading', () => {
const onStop = jest.fn();
render(<ChatInput onSend={jest.fn()} onStop={onStop} isLoading />);
expect(screen.queryByLabelText('Send message')).toBeNull();
fireEvent.press(screen.getByLabelText('Stop generating'));
expect(onStop).toHaveBeenCalledTimes(1);
});
it('fills the field when editing and lets the user cancel', () => {
const onCancelEdit = jest.fn();
render(<ChatInput onSend={jest.fn()} editingText="old text" onCancelEdit={onCancelEdit} />);
expect(screen.getByText('Editing message')).toBeTruthy();
expect(screen.getByLabelText('Message').props.value).toBe('old text');
fireEvent.press(screen.getByLabelText('Cancel editing'));
expect(onCancelEdit).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('Message').props.value).toBe('');
});
});
-221
View File
@@ -1,221 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';
import ChatScreen from '../app/chat/index';
import * as api from '../services/lmStudioApi';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
jest.mock('react-native-reanimated', () => {
const { View } = require('react-native');
const chain: any = new Proxy(() => chain, { get: () => chain });
return { __esModule: true, default: { View }, FadeInDown: chain };
});
jest.mock('../components/chat/MessageBubble', () => {
const { Text } = require('react-native');
return {
MessageBubble: ({ message, isLastAssistant }: any) => (
<Text testID={`bubble-${message.id}`}>{`${message.role}:${message.content}${isLastAssistant ? ':last' : ''}`}</Text>
),
};
});
jest.mock('../components/chat/TypingIndicator', () => {
const { Text } = require('react-native');
return { TypingIndicator: () => <Text>typing-indicator</Text> };
});
let inputProps: any;
jest.mock('../components/chat/ChatInput', () => ({
ChatInput: (props: any) => {
inputProps = props;
return null;
},
}));
const streamMock = api.chatCompletionStream as jest.Mock;
const state = () => useAppStore.getState();
beforeEach(() => {
jest.useFakeTimers();
streamMock.mockReset();
useAppStore.setState(useAppStore.getInitialState(), true);
useAppStore.setState({ isConnected: true, activeModelId: 'model-a' });
});
afterEach(() => {
jest.useRealTimers();
});
describe('ChatScreen', () => {
it('shows the empty state with prompt suggestions when there are no messages', () => {
render(<ChatScreen />);
expect(screen.getByText('Start a conversation')).toBeTruthy();
expect(screen.getByText('Explain recursion in simple terms')).toBeTruthy();
});
it('sends a suggestion as a user message and starts streaming', () => {
render(<ChatScreen />);
fireEvent.press(screen.getByText('Explain recursion in simple terms'));
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual([
'user:Explain recursion in simple terms',
'assistant:',
]);
expect(streamMock).toHaveBeenCalledTimes(1);
expect(streamMock.mock.calls[0][1]).toBe('model-a');
});
it('hides the empty state and lists the messages once there are some', () => {
state().addMessage({ role: 'user', content: 'hello' });
render(<ChatScreen />);
expect(screen.queryByText('Start a conversation')).toBeNull();
expect(screen.getByText('user:hello')).toBeTruthy();
});
it('shows the offline banner only when the server is not connected', () => {
const { rerender } = render(<ChatScreen />);
expect(screen.queryByText(/Server offline/)).toBeNull();
act(() => {
state().setIsConnected(false);
});
rerender(<ChatScreen />);
expect(screen.getByText(/Server offline/)).toBeTruthy();
});
it('shows the typing indicator only while an empty assistant message is streaming', () => {
state().addMessage({ role: 'user', content: 'q' });
const assistantId = state().addMessage({ role: 'assistant', content: '' });
act(() => {
state().setIsStreaming(true);
});
render(<ChatScreen />);
expect(screen.getByText('typing-indicator')).toBeTruthy();
act(() => {
state().appendToMessage(assistantId, 'partial');
});
expect(screen.queryByText('typing-indicator')).toBeNull();
});
it('does not show the typing indicator when not streaming', () => {
state().addMessage({ role: 'assistant', content: '' });
render(<ChatScreen />);
expect(screen.queryByText('typing-indicator')).toBeNull();
});
it('marks only the last assistant message as regenerable', () => {
state().addMessage({ role: 'user', content: 'q1' });
state().addMessage({ role: 'assistant', content: 'a1' });
state().addMessage({ role: 'user', content: 'q2' });
state().addMessage({ role: 'assistant', content: 'a2' });
render(<ChatScreen />);
expect(screen.getByText('assistant:a1')).toBeTruthy();
expect(screen.getByText('assistant:a2:last')).toBeTruthy();
});
it('sends typed text through the normal path', () => {
render(<ChatScreen />);
act(() => {
inputProps.onSend('typed');
});
expect(state().messages[0]).toMatchObject({ role: 'user', content: 'typed' });
expect(streamMock).toHaveBeenCalledTimes(1);
});
describe('editing a message', () => {
const setup = () => {
state().addMessage({ role: 'user', content: 'old question' });
state().addMessage({ role: 'assistant', content: 'old answer' });
const id = state().messages[0].id;
render(<ChatScreen />);
act(() => {
state().setEditingMessageId(id);
});
return id;
};
it('passes the edited message text to the input and clears edit mode on cancel', () => {
setup();
expect(inputProps.editingText).toBe('old question');
act(() => {
inputProps.onCancelEdit();
});
expect(state().editingMessageId).toBeNull();
});
it('rewrites the message, drops the answers after it and regenerates', () => {
const id = setup();
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
expect(state().editingMessageId).toBeNull();
expect(state().messages[0]).toMatchObject({ id, content: 'new question', isEdited: true });
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual([
'user:new question',
'assistant:',
]);
expect(streamMock).toHaveBeenCalledTimes(1);
const [, modelId, apiMessages] = streamMock.mock.calls[0];
expect(modelId).toBe('model-a');
expect(apiMessages.slice(1)).toEqual([{ role: 'user', content: 'new question' }]);
expect(apiMessages[0].role).toBe('system');
});
it('edits and explains that a model is needed when none is selected', () => {
const id = setup();
act(() => {
useAppStore.setState({ activeModelId: '' });
});
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
expect(state().messages).toHaveLength(2);
expect(state().messages[0]).toMatchObject({ id, content: 'new question' });
expect(state().messages[1].content).toContain('No model selected');
expect(streamMock).not.toHaveBeenCalled();
});
it('lets the user stop an answer generated after an edit', () => {
const abort = jest.fn();
streamMock.mockReturnValue({ abort });
setup();
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
act(() => {
inputProps.onStop();
});
expect(abort).toHaveBeenCalledTimes(1);
expect(state().isStreaming).toBe(false);
});
it('saves the edited conversation when the regenerated answer finishes', () => {
streamMock.mockImplementation((_c, _m, _msgs, onChunk, onDone) => {
onChunk('fresh answer');
onDone();
return { abort: jest.fn() };
});
setup();
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
expect(state().chatSessions).toHaveLength(1);
expect(state().chatSessions[0].messages.map((m) => m.content)).toEqual(['new question', 'fresh answer']);
});
});
});
-154
View File
@@ -1,154 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import * as Clipboard from 'expo-clipboard';
import React from 'react';
import { MessageBubble } from '../components/chat/MessageBubble';
import { ChatMessage } from '../store/useAppStore';
jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn(() => Promise.resolve(true)) }));
let actionsProps: any;
jest.mock('../components/chat/MessageActions', () => ({
MessageActions: (props: any) => {
actionsProps = props;
const { Text } = require('react-native');
return <Text>actions</Text>;
},
}));
const message = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
id: '1',
role: 'assistant',
content: 'hello **world**',
timestamp: new Date(2026, 0, 1, 13, 5).getTime(),
...overrides,
});
beforeEach(() => {
actionsProps = undefined;
(Clipboard.setStringAsync as jest.Mock).mockClear();
});
describe('MessageBubble user message', () => {
it('shows the plain text and keeps the actions hidden until touched', () => {
render(<MessageBubble message={message({ role: 'user', content: 'hi there' })} />);
expect(screen.getByText('hi there')).toBeTruthy();
expect(screen.queryByText('actions')).toBeNull();
});
it('reveals edit and delete (but not regenerate) after a touch', () => {
const onEdit = jest.fn();
const onDelete = jest.fn();
const onRegenerate = jest.fn();
render(
<MessageBubble
message={message({ role: 'user', content: 'hi' })}
onEdit={onEdit}
onDelete={onDelete}
onRegenerate={onRegenerate}
isLastAssistant
/>,
);
fireEvent(screen.getByText('hi').parent!.parent!.parent!.parent!, 'touchStart');
expect(screen.getByText('actions')).toBeTruthy();
expect(actionsProps.role).toBe('user');
expect(actionsProps.onEdit).toBe(onEdit);
expect(actionsProps.onDelete).toBe(onDelete);
expect(actionsProps.onRegenerate).toBeUndefined();
});
it('never renders actions for an empty message', () => {
render(<MessageBubble message={message({ role: 'user', content: '' })} />);
fireEvent(screen.getByText(/\d/).parent!.parent!.parent!, 'touchStart');
expect(screen.queryByText('actions')).toBeNull();
});
});
describe('MessageBubble assistant message', () => {
it('renders markdown and always shows actions', () => {
render(<MessageBubble message={message()} />);
expect(screen.getByText('world')).toBeTruthy();
expect(screen.getByText('actions')).toBeTruthy();
});
it('offers regenerate only on the last assistant message and never edit', () => {
const onEdit = jest.fn();
const onRegenerate = jest.fn();
const { rerender } = render(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} />);
expect(actionsProps.onRegenerate).toBeUndefined();
expect(actionsProps.onEdit).toBeUndefined();
rerender(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} isLastAssistant />);
expect(actionsProps.onRegenerate).toBe(onRegenerate);
expect(actionsProps.onEdit).toBeUndefined();
});
it('flags actions as streaming only for the last assistant message', () => {
const { rerender } = render(<MessageBubble message={message()} isStreaming />);
expect(actionsProps.isStreaming).toBeFalsy();
rerender(<MessageBubble message={message()} isStreaming isLastAssistant />);
expect(actionsProps.isStreaming).toBe(true);
});
it('renders nothing inside the bubble or actions while an empty answer is streaming', () => {
render(<MessageBubble message={message({ content: '' })} isStreaming isLastAssistant />);
expect(screen.queryByText('actions')).toBeNull();
});
it('renders an empty answer as markdown when not streaming', () => {
render(<MessageBubble message={message({ content: '' })} />);
expect(screen.queryByText('actions')).toBeNull();
});
});
describe('MessageBubble metadata', () => {
it('shows the time and the edited badge only for edited messages', () => {
const { rerender } = render(<MessageBubble message={message({ role: 'user' })} />);
expect(screen.queryByText(/edited/)).toBeNull();
rerender(<MessageBubble message={message({ role: 'user', isEdited: true })} />);
expect(screen.getByText(/· edited/)).toBeTruthy();
});
});
describe('MessageBubble code blocks', () => {
const fenced = 'text\n\n```ts\nconst a = 1;\n```\n';
it('shows the language, the code and copies it to the clipboard', async () => {
jest.useFakeTimers();
try {
render(<MessageBubble message={message({ content: fenced })} />);
expect(screen.getByText('ts')).toBeTruthy();
expect(screen.getByText(/const a = 1;/)).toBeTruthy();
expect(screen.getByText('Copy')).toBeTruthy();
await act(async () => {
fireEvent.press(screen.getByText('Copy'));
});
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('const a = 1;\n');
expect(screen.getByText('Copied!')).toBeTruthy();
act(() => {
jest.advanceTimersByTime(2000);
});
expect(screen.getByText('Copy')).toBeTruthy();
} finally {
jest.useRealTimers();
}
});
it('labels a fence without a language as "code"', () => {
render(<MessageBubble message={message({ content: '```\nplain\n```\n' })} />);
expect(screen.getByText('code')).toBeTruthy();
});
it('renders indented code blocks with a "code" header and copy button', async () => {
render(<MessageBubble message={message({ content: 'intro\n\n indented()\n' })} />);
expect(screen.getByText('code')).toBeTruthy();
await act(async () => {
fireEvent.press(screen.getByText('Copy'));
});
expect(Clipboard.setStringAsync).toHaveBeenCalledWith(expect.stringContaining('indented()'));
});
});
-78
View File
@@ -1,78 +0,0 @@
import { render, screen } from '@testing-library/react-native';
import React from 'react';
import { MessageList } from '../components/chat/MessageList';
import { ChatMessage } from '../store/useAppStore';
jest.mock('react-native-reanimated', () => {
const { View } = require('react-native');
const chain: any = new Proxy(() => chain, { get: () => chain });
return { __esModule: true, default: { View }, FadeInDown: chain };
});
const mockRenders: Record<string, number> = {};
jest.mock('../components/chat/MessageBubble', () => {
const { Text } = require('react-native');
return {
MessageBubble: ({ message }: any) => {
mockRenders[message.id] = (mockRenders[message.id] ?? 0) + 1;
return <Text>{`${message.id}:${message.content}`}</Text>;
},
};
});
jest.mock('../components/chat/TypingIndicator', () => {
const { Text } = require('react-native');
return { TypingIndicator: () => <Text>typing-indicator</Text> };
});
const msg = (id: string, role: ChatMessage['role'], content: string): ChatMessage => ({
id,
role,
content,
timestamp: 0,
});
const handlers = { onEdit: jest.fn(), onDelete: jest.fn(), onRegenerate: jest.fn() };
beforeEach(() => {
Object.keys(mockRenders).forEach((k) => delete mockRenders[k]);
});
describe('MessageList', () => {
it('renders every message', () => {
render(
<MessageList
messages={[msg('u1', 'user', 'q'), msg('a1', 'assistant', 'a')]}
isStreaming={false}
{...handlers}
/>,
);
expect(screen.getByText('u1:q')).toBeTruthy();
expect(screen.getByText('a1:a')).toBeTruthy();
});
it('re-renders only the message that changed while an answer streams in', () => {
const user = msg('u1', 'user', 'question');
const earlier = msg('a0', 'assistant', 'earlier answer');
const streaming = msg('a1', 'assistant', 'He');
const { rerender } = render(
<MessageList messages={[user, earlier, streaming]} isStreaming {...handlers} />,
);
expect(mockRenders).toEqual({ u1: 1, a0: 1, a1: 1 });
rerender(
<MessageList messages={[user, earlier, { ...streaming, content: 'Hello' }]} isStreaming {...handlers} />,
);
expect(screen.getByText('a1:Hello')).toBeTruthy();
expect(mockRenders.u1).toBe(1);
expect(mockRenders.a0).toBe(1);
expect(mockRenders.a1).toBe(2);
});
it('shows the typing indicator while the last assistant message is still empty', () => {
render(
<MessageList messages={[msg('u1', 'user', 'q'), msg('a1', 'assistant', '')]} isStreaming {...handlers} />,
);
expect(screen.getByText('typing-indicator')).toBeTruthy();
});
});
-172
View File
@@ -1,172 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';
import SettingsScreen from '../app/settings/index';
import * as api from '../services/lmStudioApi';
import { DEFAULT_SERVER_CONFIG } from '../services/types';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
let mockSystemScheme: 'light' | 'dark' = 'light';
jest.mock('../hooks/useColorScheme', () => ({
useColorScheme: () => mockSystemScheme,
}));
const mockApi = api as jest.Mocked<typeof api>;
const state = () => useAppStore.getState();
const hostInput = () => screen.getByPlaceholderText('localhost');
const portInput = () => screen.getByPlaceholderText('1234');
const keyInput = () => screen.getByPlaceholderText('Leave empty for local');
beforeEach(() => {
jest.resetAllMocks();
mockSystemScheme = 'light';
useAppStore.setState(useAppStore.getInitialState(), true);
mockApi.getModels.mockResolvedValue([]);
});
describe('SettingsScreen connection form', () => {
it('is filled from the stored configuration and hides "Save Changes" until something changes', () => {
useAppStore.setState({ serverConfig: { host: '192.168.0.5', port: 4321, apiKey: 'k' } });
render(<SettingsScreen />);
expect(hostInput().props.value).toBe('192.168.0.5');
expect(portInput().props.value).toBe('4321');
expect(keyInput().props.value).toBe('k');
expect(screen.queryByText('Save Changes')).toBeNull();
});
it('shows "Save Changes" for each changed field', () => {
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), 'other');
expect(screen.getByText('Save Changes')).toBeTruthy();
fireEvent.changeText(hostInput(), DEFAULT_SERVER_CONFIG.host);
expect(screen.queryByText('Save Changes')).toBeNull();
fireEvent.changeText(portInput(), '999');
expect(screen.getByText('Save Changes')).toBeTruthy();
fireEvent.changeText(portInput(), String(DEFAULT_SERVER_CONFIG.port));
expect(screen.queryByText('Save Changes')).toBeNull();
fireEvent.changeText(keyInput(), 'secret');
expect(screen.getByText('Save Changes')).toBeTruthy();
});
it('saves trimmed values', () => {
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), ' 192.168.0.9 ');
fireEvent.changeText(portInput(), '8080');
fireEvent.changeText(keyInput(), ' key ');
fireEvent.press(screen.getByText('Save Changes'));
expect(state().serverConfig).toEqual({ host: '192.168.0.9', port: 8080, apiKey: 'key' });
});
it('falls back to localhost:1234 and no API key when the fields are blank or invalid', () => {
useAppStore.setState({ serverConfig: { host: 'lan', port: 4321, apiKey: 'k' } });
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), ' ');
fireEvent.changeText(portInput(), 'abc');
fireEvent.changeText(keyInput(), ' ');
fireEvent.press(screen.getByText('Save Changes'));
expect(state().serverConfig).toEqual({ host: 'localhost', port: 1234, apiKey: undefined });
});
});
describe('SettingsScreen connection test', () => {
it('saves the configuration, reports success and loads the models', async () => {
mockApi.testConnection.mockResolvedValue(true);
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), '10.0.0.2');
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(mockApi.testConnection).toHaveBeenCalledWith({ host: '10.0.0.2', port: 1234, apiKey: undefined });
expect(state().serverConfig.host).toBe('10.0.0.2');
expect(state().isConnected).toBe(true);
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
expect(screen.getByText('Connection successful. API is ready.')).toBeTruthy();
expect(screen.getByText('Connected')).toBeTruthy();
});
it('reports failure and does not load models', async () => {
mockApi.testConnection.mockResolvedValue(false);
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(state().isConnected).toBe(false);
expect(mockApi.getModels).not.toHaveBeenCalled();
expect(screen.getByText('Connection failed. Verify host and port.')).toBeTruthy();
expect(screen.getByText('Disconnected')).toBeTruthy();
});
it('shows a spinner and ignores presses while the test is running', async () => {
let resolveTest: (ok: boolean) => void = () => {};
mockApi.testConnection.mockReturnValue(new Promise((resolve) => (resolveTest = resolve)));
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(screen.queryByText('Test Connection')).toBeNull();
await act(async () => {
resolveTest(false);
});
expect(screen.getByText('Test Connection')).toBeTruthy();
});
it('clears a previous result when the configuration is saved again', async () => {
mockApi.testConnection.mockResolvedValue(false);
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(screen.getByText(/Connection failed/)).toBeTruthy();
fireEvent.changeText(hostInput(), 'changed');
fireEvent.press(screen.getByText('Save Changes'));
expect(screen.queryByText(/Connection failed/)).toBeNull();
});
});
describe('SettingsScreen dark mode switch', () => {
const darkSwitch = () => screen.UNSAFE_getByType(require('react-native').Switch);
it('is on for the dark preference and switches to light', () => {
useAppStore.setState({ theme: 'dark' });
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(true);
fireEvent(darkSwitch(), 'valueChange', false);
expect(state().theme).toBe('light');
});
it('is off for the light preference and switches to dark', () => {
useAppStore.setState({ theme: 'light' });
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(false);
fireEvent(darkSwitch(), 'valueChange', true);
expect(state().theme).toBe('dark');
});
it('follows the system scheme when the preference is "system"', () => {
mockSystemScheme = 'dark';
const first = render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(true);
first.unmount();
mockSystemScheme = 'light';
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(false);
});
});
-72
View File
@@ -1,72 +0,0 @@
import { createDebouncedStorage } from '../store/debouncedStorage';
const makeBase = () => ({
getItem: jest.fn(async (_name: string): Promise<string | null> => 'from-disk'),
setItem: jest.fn(async (_name: string, _value: string) => {}),
removeItem: jest.fn(async (_name: string) => {}),
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('createDebouncedStorage', () => {
it('collapses a burst of writes into one trailing write with the latest value', () => {
const base = makeBase();
const storage = createDebouncedStorage(base, 400);
storage.setItem('k', '1');
jest.advanceTimersByTime(300);
storage.setItem('k', '2');
jest.advanceTimersByTime(300);
storage.setItem('k', '3');
expect(base.setItem).not.toHaveBeenCalled();
jest.advanceTimersByTime(400);
expect(base.setItem).toHaveBeenCalledTimes(1);
expect(base.setItem).toHaveBeenCalledWith('k', '3');
});
it('reads a pending value before it reaches the disk, and falls back to the base storage', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
await expect(storage.getItem('k')).resolves.toBe('from-disk');
storage.setItem('k', 'fresh');
await expect(storage.getItem('k')).resolves.toBe('fresh');
});
it('flush writes immediately and cancels the timer', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
storage.setItem('a', '1');
storage.setItem('b', '2');
await storage.flush();
expect(base.setItem.mock.calls).toEqual([['a', '1'], ['b', '2']]);
jest.advanceTimersByTime(1000);
expect(base.setItem).toHaveBeenCalledTimes(2);
});
it('flush with nothing pending does not write', async () => {
const base = makeBase();
await createDebouncedStorage(base).flush();
expect(base.setItem).not.toHaveBeenCalled();
});
it('removeItem drops a pending write and removes from the base storage', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
storage.setItem('k', '1');
await storage.removeItem('k');
jest.advanceTimersByTime(1000);
expect(base.setItem).not.toHaveBeenCalled();
expect(base.removeItem).toHaveBeenCalledWith('k');
});
});
-390
View File
@@ -1,390 +0,0 @@
import { Platform } from 'react-native';
import {
buildBaseUrl,
chatCompletion,
chatCompletionStream,
getModels,
parseSSELines,
testConnection,
} from '../services/lmStudioApi';
import { ServerConfig } from '../services/types';
const config: ServerConfig = { host: '192.168.0.10', port: 1234 };
const fetchMock = jest.fn();
const originalFetch = global.fetch;
beforeEach(() => {
fetchMock.mockReset();
global.fetch = fetchMock as unknown as typeof fetch;
});
afterAll(() => {
global.fetch = originalFetch;
});
const sseLine = (content: string) => `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}`;
describe('buildBaseUrl', () => {
it('prepends http:// and appends port and /v1', () => {
expect(buildBaseUrl(config)).toBe('http://192.168.0.10:1234/v1');
});
it('keeps an explicit http or https scheme', () => {
expect(buildBaseUrl({ host: 'http://lan.local', port: 8080 })).toBe('http://lan.local:8080/v1');
expect(buildBaseUrl({ host: 'https://lan.local', port: 443 })).toBe('https://lan.local:443/v1');
});
it('strips trailing slashes from the host', () => {
expect(buildBaseUrl({ host: 'http://lan.local//', port: 1234 })).toBe('http://lan.local:1234/v1');
});
it('does not mistake a host that merely starts with "http" for a scheme', () => {
expect(buildBaseUrl({ host: 'httpbin.local', port: 1234 })).toBe('http://httpbin.local:1234/v1');
});
});
describe('parseSSELines', () => {
it('emits content of every complete data line and keeps the partial tail', () => {
const onChunk = jest.fn();
const onDone = jest.fn();
const result = parseSSELines(`${sseLine('Hel')}\n${sseLine('lo')}\ndata: {"cho`, onChunk, onDone);
expect(onChunk.mock.calls).toEqual([['Hel'], ['lo']]);
expect(result).toEqual({ remaining: 'data: {"cho', isDone: false });
expect(onDone).not.toHaveBeenCalled();
});
it('stops at [DONE] and calls onDone', () => {
const onChunk = jest.fn();
const onDone = jest.fn();
const result = parseSSELines(`${sseLine('a')}\ndata: [DONE]\n${sseLine('ignored')}\n`, onChunk, onDone);
expect(onChunk.mock.calls).toEqual([['a']]);
expect(onDone).toHaveBeenCalledTimes(1);
expect(result).toEqual({ remaining: '', isDone: true });
});
it('skips malformed JSON, empty deltas, comments and non-data lines', () => {
const onChunk = jest.fn();
parseSSELines(
[
'data: {not json}',
'data: {"choices":[{"delta":{}}]}',
'data: {"choices":[]}',
': keep-alive',
'event: message',
'',
sseLine('ok'),
'',
].join('\n'),
onChunk,
jest.fn(),
);
expect(onChunk.mock.calls).toEqual([['ok']]);
});
it('handles CRLF line endings and "data:" without a space', () => {
const onChunk = jest.fn();
const noSpace = `data:${JSON.stringify({ choices: [{ delta: { content: 'nospace' } }] })}`;
parseSSELines(`${sseLine('crlf')}\r\n${noSpace}\n`, onChunk, jest.fn());
expect(onChunk.mock.calls).toEqual([['crlf'], ['nospace']]);
});
});
describe('testConnection', () => {
it('sends the Authorization header only when an API key is set', async () => {
fetchMock.mockResolvedValue({ ok: true });
await testConnection({ ...config, apiKey: 'secret' });
await testConnection({ ...config, apiKey: '' });
expect(fetchMock.mock.calls[0][0]).toBe('http://192.168.0.10:1234/v1/models');
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Bearer secret' });
expect(fetchMock.mock.calls[1][1].headers).not.toHaveProperty('Authorization');
});
it('returns true on a 2xx response and false otherwise', async () => {
fetchMock.mockResolvedValueOnce({ ok: true });
await expect(testConnection(config)).resolves.toBe(true);
fetchMock.mockResolvedValueOnce({ ok: false });
await expect(testConnection(config)).resolves.toBe(false);
});
it('returns false when the network call throws', async () => {
fetchMock.mockRejectedValue(new TypeError('Network request failed'));
await expect(testConnection(config)).resolves.toBe(false);
});
it('aborts after 5 seconds and returns false', async () => {
jest.useFakeTimers();
try {
fetchMock.mockImplementation(
(_url: string, init: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => reject(new Error('aborted')));
}),
);
const pending = testConnection(config);
await jest.advanceTimersByTimeAsync(5000);
await expect(pending).resolves.toBe(false);
} finally {
jest.useRealTimers();
}
});
it('does not leave the timeout timer running after a failure', async () => {
jest.useFakeTimers();
try {
fetchMock.mockRejectedValue(new TypeError('Network request failed'));
await testConnection(config);
expect(jest.getTimerCount()).toBe(0);
} finally {
jest.useRealTimers();
}
});
});
describe('getModels', () => {
it('returns the data array', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ data: [{ id: 'm1' }] }) });
await expect(getModels(config)).resolves.toEqual([{ id: 'm1' }]);
});
it('falls back to an empty list when data is missing', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
await expect(getModels(config)).resolves.toEqual([]);
});
it('throws with the HTTP status when the response is not ok', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' });
await expect(getModels(config)).rejects.toThrow('Failed to fetch models: 401 Unauthorized');
});
});
describe('chatCompletion', () => {
it('posts a non-streaming request with defaults that options can override', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'x' }) });
await chatCompletion(config, 'model-a', [{ role: 'user', content: 'hi' }], { temperature: 0.1 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://192.168.0.10:1234/v1/chat/completions');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({
model: 'model-a',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
temperature: 0.1,
max_tokens: 2048,
});
});
it('throws with the HTTP status when the response is not ok', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' });
await expect(chatCompletion(config, 'm', [])).rejects.toThrow('Chat completion failed: 500 Server Error');
});
});
describe('chatCompletionStream (web, fetch + ReadableStream)', () => {
const originalOS = Platform.OS;
const encoder = new TextEncoder();
beforeEach(() => {
Platform.OS = 'web';
});
afterEach(() => {
Platform.OS = originalOS;
});
const streamOf = (parts: string[]) =>
new ReadableStream<Uint8Array>({
start(controller) {
parts.forEach((p) => controller.enqueue(encoder.encode(p)));
controller.close();
},
});
// Starts a stream and resolves once either onDone or onError fired.
const run = () => {
const onChunk = jest.fn();
const onDone = jest.fn();
const onError = jest.fn();
const finished = new Promise<void>((resolve) => {
chatCompletionStream(
config,
'm',
[],
onChunk,
() => {
onDone();
resolve();
},
(e) => {
onError(e);
resolve();
},
);
});
return { onChunk, onDone, onError, finished };
};
it('streams chunks split across reads and calls onDone exactly once', async () => {
const whole = `${sseLine('Hi')}\n${sseLine(' there')}\ndata: [DONE]\n`;
fetchMock.mockResolvedValue({ ok: true, body: streamOf([whole.slice(0, 20), whole.slice(20)]) });
const { onChunk, onDone, onError, finished } = run();
await finished;
expect(onChunk.mock.calls).toEqual([['Hi'], [' there']]);
expect(onDone).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled();
expect(JSON.parse(fetchMock.mock.calls[0][1].body).stream).toBe(true);
});
it('calls onDone when the stream ends without [DONE]', async () => {
fetchMock.mockResolvedValue({ ok: true, body: streamOf([`${sseLine('x')}\n`]) });
const { onChunk, onDone, finished } = run();
await finished;
expect(onChunk).toHaveBeenCalledWith('x');
expect(onDone).toHaveBeenCalledTimes(1);
});
it('reports an HTTP error through onError', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' });
const { onError, finished } = run();
await finished;
expect(onError.mock.calls[0][0].message).toBe('Stream failed: 404 Not Found');
});
it('turns a network failure into an actionable connection error', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
const { onError, finished } = run();
await finished;
expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1');
});
it('does not report an error when the request is aborted', async () => {
fetchMock.mockImplementation(
(_url: string, init: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
}),
);
const onError = jest.fn();
const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
handle.abort();
await new Promise((r) => setTimeout(r, 0));
expect(onError).not.toHaveBeenCalled();
});
});
describe('chatCompletionStream (native, XMLHttpRequest)', () => {
class FakeXHR {
static last: FakeXHR;
status = 200;
statusText = 'OK';
responseText = '';
headers: Record<string, string> = {};
aborted = false;
onprogress: (() => void) | null = null;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
ontimeout: (() => void) | null = null;
open = jest.fn();
send = jest.fn();
constructor() {
FakeXHR.last = this;
}
setRequestHeader(k: string, v: string) {
this.headers[k] = v;
}
abort() {
this.aborted = true;
}
}
const originalXHR = global.XMLHttpRequest;
beforeEach(() => {
global.XMLHttpRequest = FakeXHR as unknown as typeof XMLHttpRequest;
});
afterEach(() => {
global.XMLHttpRequest = originalXHR;
});
it('parses progress events incrementally and finishes once', () => {
const onChunk = jest.fn();
const onDone = jest.fn();
chatCompletionStream({ ...config, apiKey: 'k' }, 'm', [], onChunk, onDone, jest.fn());
const xhr = FakeXHR.last;
expect(xhr.open).toHaveBeenCalledWith('POST', 'http://192.168.0.10:1234/v1/chat/completions');
expect(xhr.headers.Authorization).toBe('Bearer k');
const full = `${sseLine('A')}\n${sseLine('B')}\n`;
xhr.responseText = full.slice(0, 30);
xhr.onprogress?.();
xhr.responseText = full;
xhr.onprogress?.();
xhr.responseText = `${full}data: [DONE]\n`;
xhr.onprogress?.();
xhr.onload?.();
expect(onChunk.mock.calls).toEqual([['A'], ['B']]);
expect(onDone).toHaveBeenCalledTimes(1);
});
it('flushes a final line that has no trailing newline on load', () => {
const onChunk = jest.fn();
chatCompletionStream(config, 'm', [], onChunk, jest.fn(), jest.fn());
const xhr = FakeXHR.last;
xhr.responseText = sseLine('tail');
xhr.onprogress?.();
xhr.onload?.();
expect(onChunk.mock.calls).toEqual([['tail']]);
});
it('reports an HTTP error status instead of finishing silently', () => {
const onDone = jest.fn();
const onError = jest.fn();
chatCompletionStream(config, 'm', [], jest.fn(), onDone, onError);
const xhr = FakeXHR.last;
xhr.status = 401;
xhr.statusText = 'Unauthorized';
xhr.responseText = '{"error":"bad key"}';
xhr.onprogress?.();
xhr.onload?.();
expect(onError.mock.calls[0][0].message).toBe('Stream failed: 401 Unauthorized');
expect(onDone).not.toHaveBeenCalled();
});
it('turns a network error into an actionable connection error', () => {
const onError = jest.fn();
chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
FakeXHR.last.status = 0;
FakeXHR.last.onerror?.();
expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1');
});
it('reports a timeout', () => {
const onError = jest.fn();
chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
FakeXHR.last.ontimeout?.();
expect(onError.mock.calls[0][0].message).toBe('Request timed out');
});
it('abort() aborts the request and swallows errors thrown by abort', () => {
const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), jest.fn());
const xhr = FakeXHR.last;
handle.abort();
expect(xhr.aborted).toBe(true);
xhr.abort = () => {
throw new Error('boom');
};
expect(() => handle.abort()).not.toThrow();
});
});
-188
View File
@@ -1,188 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { appStorage } from '../store/debouncedStorage';
import { useAppStore } from '../store/useAppStore';
const state = () => useAppStore.getState();
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true);
});
describe('messages', () => {
it('addMessage appends with a generated id and timestamp, and returns the id', () => {
const id = state().addMessage({ role: 'user', content: 'hi' });
const [msg] = state().messages;
expect(msg.id).toBe(id);
expect(msg).toMatchObject({ role: 'user', content: 'hi' });
expect(typeof msg.timestamp).toBe('number');
});
it('generates distinct ids for messages created in the same millisecond', () => {
const ids = new Set(Array.from({ length: 50 }, () => state().addMessage({ role: 'user', content: 'x' })));
expect(ids.size).toBe(50);
});
it('appendToMessage and updateMessageContent only touch the target message', () => {
const a = state().addMessage({ role: 'assistant', content: 'He' });
const b = state().addMessage({ role: 'assistant', content: 'other' });
state().appendToMessage(a, 'llo');
expect(state().messages.map((m) => m.content)).toEqual(['Hello', 'other']);
state().updateMessageContent(b, 'replaced');
expect(state().messages.map((m) => m.content)).toEqual(['Hello', 'replaced']);
});
it('editMessage replaces the content and marks it as edited', () => {
const id = state().addMessage({ role: 'user', content: 'old' });
state().editMessage(id, 'new');
expect(state().messages[0]).toMatchObject({ content: 'new', isEdited: true });
});
it('deleteMessage removes only the given message', () => {
const a = state().addMessage({ role: 'user', content: 'a' });
state().addMessage({ role: 'assistant', content: 'b' });
state().deleteMessage(a);
expect(state().messages.map((m) => m.content)).toEqual(['b']);
});
describe('deleteMessagesAfter', () => {
it('keeps the given message and drops everything after it', () => {
const a = state().addMessage({ role: 'user', content: 'a' });
state().addMessage({ role: 'assistant', content: 'b' });
state().addMessage({ role: 'user', content: 'c' });
state().deleteMessagesAfter(a);
expect(state().messages.map((m) => m.content)).toEqual(['a']);
});
it('does nothing when the id is unknown', () => {
state().addMessage({ role: 'user', content: 'a' });
state().deleteMessagesAfter('missing');
expect(state().messages).toHaveLength(1);
});
});
it('clearChat empties the conversation and forgets the current session', () => {
state().addMessage({ role: 'user', content: 'a' });
state().setCurrentChatId('abc');
state().clearChat();
expect(state().messages).toEqual([]);
expect(state().currentChatId).toBeNull();
});
});
describe('chat sessions', () => {
it('saveCurrentChat ignores an empty conversation', () => {
state().saveCurrentChat();
expect(state().chatSessions).toEqual([]);
expect(state().currentChatId).toBeNull();
});
it('creates a session titled by the first user message and makes it current', () => {
state().addMessage({ role: 'assistant', content: 'welcome' });
state().addMessage({ role: 'user', content: 'first question' });
state().saveCurrentChat();
expect(state().chatSessions).toHaveLength(1);
const [session] = state().chatSessions;
expect(session.title).toBe('first question');
expect(session.messages).toHaveLength(2);
expect(state().currentChatId).toBe(session.id);
});
it('truncates long titles to 40 characters plus an ellipsis', () => {
state().addMessage({ role: 'user', content: 'x'.repeat(60) });
state().saveCurrentChat();
expect(state().chatSessions[0].title).toBe(`${'x'.repeat(40)}...`);
});
it('falls back to "New Chat" when there is no user message', () => {
state().addMessage({ role: 'assistant', content: 'hello' });
state().saveCurrentChat();
expect(state().chatSessions[0].title).toBe('New Chat');
});
it('updates the existing session instead of creating another one', () => {
state().addMessage({ role: 'user', content: 'q' });
state().saveCurrentChat();
const firstId = state().currentChatId;
state().addMessage({ role: 'assistant', content: 'a' });
state().saveCurrentChat();
expect(state().chatSessions).toHaveLength(1);
expect(state().chatSessions[0].id).toBe(firstId);
expect(state().chatSessions[0].messages).toHaveLength(2);
});
it('only rewrites the session that matches currentChatId', () => {
useAppStore.setState({
chatSessions: [
{ id: '1', title: 'one', updatedAt: 1, messages: [] },
{ id: '2', title: 'two', updatedAt: 2, messages: [] },
],
currentChatId: '2',
messages: [{ id: 'm', role: 'user', content: 'changed', timestamp: 3 }],
});
state().saveCurrentChat();
expect(state().chatSessions[0]).toMatchObject({ id: '1', title: 'one' });
expect(state().chatSessions[1]).toMatchObject({ id: '2', title: 'changed' });
});
it('loadChat restores the messages of a session, and ignores unknown ids', () => {
const messages = [{ id: 'm', role: 'user' as const, content: 'saved', timestamp: 1 }];
useAppStore.setState({ chatSessions: [{ id: 's', title: 't', updatedAt: 1, messages }] });
state().loadChat('missing');
expect(state().messages).toEqual([]);
state().loadChat('s');
expect(state().messages).toEqual(messages);
expect(state().currentChatId).toBe('s');
});
it('deleteChatSession resets the open conversation only when it deletes the current one', () => {
const session = (id: string) => ({ id, title: id, updatedAt: 1, messages: [] });
useAppStore.setState({
chatSessions: [session('a'), session('b')],
currentChatId: 'a',
messages: [{ id: 'm', role: 'user', content: 'x', timestamp: 1 }],
});
state().deleteChatSession('b');
expect(state().chatSessions.map((s) => s.id)).toEqual(['a']);
expect(state().messages).toHaveLength(1);
state().deleteChatSession('a');
expect(state().chatSessions).toEqual([]);
expect(state().messages).toEqual([]);
expect(state().currentChatId).toBeNull();
});
});
describe('persistence', () => {
it('persists settings, the selected model and chats but not transient connection or streaming state', async () => {
useAppStore.setState({
theme: 'dark',
serverConfig: { host: 'lan.local', port: 4321, apiKey: 'k' },
isConnected: true,
isStreaming: true,
activeModelId: 'model',
availableModels: [{ id: 'model', object: 'model', owned_by: 'x' }],
editingMessageId: 'e',
});
state().addMessage({ role: 'user', content: 'persist me' });
await appStorage.flush();
const raw = await AsyncStorage.getItem('localaiapp-storage');
const persisted = JSON.parse(raw as string).state;
expect(Object.keys(persisted).sort()).toEqual(
['activeModelId', 'chatSessions', 'currentChatId', 'messages', 'serverConfig', 'theme'],
);
expect(persisted.serverConfig).toEqual({ host: 'lan.local', port: 4321, apiKey: 'k' });
expect(persisted.activeModelId).toBe('model');
expect(persisted.messages[0].content).toBe('persist me');
});
});
-42
View File
@@ -1,42 +0,0 @@
import { act, renderHook } from '@testing-library/react-native';
import { useAutoConnect } from '../hooks/useAutoConnect';
import * as api from '../services/lmStudioApi';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
const mockApi = api as jest.Mocked<typeof api>;
beforeEach(() => {
jest.resetAllMocks();
useAppStore.setState(useAppStore.getInitialState(), true);
});
describe('useAutoConnect', () => {
it('loads the models once on mount and marks the server connected', async () => {
mockApi.getModels.mockResolvedValue([{ id: 'm1', object: 'model', owned_by: 'x' }]);
renderHook(() => useAutoConnect());
await act(async () => {});
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
expect(useAppStore.getState().isConnected).toBe(true);
expect(useAppStore.getState().activeModelId).toBe('m1');
});
it('leaves the app offline when the server does not answer', async () => {
mockApi.getModels.mockRejectedValue(new Error('down'));
renderHook(() => useAutoConnect());
await act(async () => {});
expect(useAppStore.getState().isConnected).toBe(false);
});
it('does not check again on re-render', async () => {
mockApi.getModels.mockResolvedValue([]);
const { rerender } = renderHook(() => useAutoConnect());
await act(async () => {});
rerender({});
await act(async () => {});
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
});
});
-246
View File
@@ -1,246 +0,0 @@
import { act, renderHook } from '@testing-library/react-native';
import { useLMStudio } from '../hooks/useLMStudio';
import * as api from '../services/lmStudioApi';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
const mockApi = api as jest.Mocked<typeof api>;
const state = () => useAppStore.getState();
type StreamArgs = Parameters<typeof api.chatCompletionStream>;
const lastStreamCall = () => mockApi.chatCompletionStream.mock.calls.at(-1) as StreamArgs;
beforeEach(() => {
jest.resetAllMocks();
useAppStore.setState(useAppStore.getInitialState(), true);
useAppStore.setState({ activeModelId: 'model-a' });
mockApi.chatCompletionStream.mockReturnValue({ abort: jest.fn() });
});
describe('checkConnection', () => {
it('stores the result of the connection test', async () => {
mockApi.testConnection.mockResolvedValue(true);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await expect(result.current.checkConnection()).resolves.toBe(true);
});
expect(state().isConnected).toBe(true);
});
});
describe('fetchModels', () => {
const models = [
{ id: 'first', object: 'model', owned_by: 'x' },
{ id: 'second', object: 'model', owned_by: 'x' },
];
it('stores the models, marks the server connected and selects the first model if none is active', async () => {
useAppStore.setState({ activeModelId: '' });
mockApi.getModels.mockResolvedValue(models);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.fetchModels();
});
expect(state().availableModels).toEqual(models);
expect(state().isConnected).toBe(true);
expect(state().activeModelId).toBe('first');
});
it('keeps the model that was already selected', async () => {
mockApi.getModels.mockResolvedValue(models);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.fetchModels();
});
expect(state().activeModelId).toBe('model-a');
});
it('does not select anything when the server has no models', async () => {
useAppStore.setState({ activeModelId: '' });
mockApi.getModels.mockResolvedValue([]);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.fetchModels();
});
expect(state().activeModelId).toBe('');
});
it('marks the server offline and clears the list when the request fails', async () => {
useAppStore.setState({ isConnected: true, availableModels: models });
mockApi.getModels.mockRejectedValue(new Error('down'));
const { result } = renderHook(() => useLMStudio());
let returned: unknown;
await act(async () => {
returned = await result.current.fetchModels();
});
expect(returned).toEqual([]);
expect(state().isConnected).toBe(false);
expect(state().availableModels).toEqual([]);
});
});
describe('sendMessage', () => {
it('streams an answer for the conversation, prefixed by the system prompt', async () => {
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
const [, modelId, apiMessages, onChunk, onDone] = lastStreamCall();
expect(modelId).toBe('model-a');
expect(apiMessages[0].role).toBe('system');
expect(apiMessages.slice(1)).toEqual([{ role: 'user', content: 'hello' }]);
expect(state().isStreaming).toBe(true);
act(() => {
onChunk('Hi');
onChunk(' there');
onDone();
});
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual(['user:hello', 'assistant:Hi there']);
expect(state().isStreaming).toBe(false);
expect(state().chatSessions).toHaveLength(1);
});
it('asks for a model instead of calling the server when none is selected', async () => {
useAppStore.setState({ activeModelId: '' });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
expect(state().messages[1].content).toContain('No model selected');
expect(state().isStreaming).toBe(false);
});
it('ignores new messages while an answer is streaming', async () => {
useAppStore.setState({ isStreaming: true });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('again');
});
expect(state().messages).toEqual([]);
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
});
it('writes the error into the assistant message and stops streaming when the stream fails', async () => {
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
const onError = lastStreamCall()[5];
act(() => {
onError(new Error('Could not reach the server'));
});
expect(state().messages[1].content).toContain('Error: Could not reach the server');
expect(state().isStreaming).toBe(false);
expect(state().chatSessions).toHaveLength(1);
});
it('reports a synchronous failure to start the stream', async () => {
mockApi.chatCompletionStream.mockImplementation(() => {
throw new Error('boom');
});
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
expect(state().messages[1].content).toContain('Connection failed: boom');
expect(state().isStreaming).toBe(false);
});
});
describe('regenerateResponse', () => {
it('drops everything after the last user message and asks again', async () => {
state().addMessage({ role: 'user', content: 'q1' });
state().addMessage({ role: 'assistant', content: 'a1' });
state().addMessage({ role: 'user', content: 'q2' });
state().addMessage({ role: 'assistant', content: 'a2' });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.regenerateResponse();
});
const apiMessages = lastStreamCall()[2];
expect(apiMessages.slice(1).map((m) => m.content)).toEqual(['q1', 'a1', 'q2']);
expect(state().messages.map((m) => m.content)).toEqual(['q1', 'a1', 'q2', '']);
expect(state().isStreaming).toBe(true);
});
it('keeps the current answer and asks for a model when none is selected', async () => {
state().addMessage({ role: 'user', content: 'q' });
state().addMessage({ role: 'assistant', content: 'a' });
useAppStore.setState({ activeModelId: '' });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.regenerateResponse();
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
expect(state().messages.map((m) => m.content)).toEqual([
'q',
'a',
expect.stringContaining('No model selected'),
]);
});
it('does nothing without a user message or while streaming', async () => {
const { result, rerender } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.regenerateResponse();
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
expect(state().messages).toEqual([]);
act(() => {
state().addMessage({ role: 'user', content: 'q' });
});
act(() => {
useAppStore.setState({ activeModelId: 'model-a', isStreaming: true });
});
rerender({});
await act(async () => {
await result.current.regenerateResponse();
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
});
});
describe('stopStreaming', () => {
it('aborts the request, stops streaming and keeps the partial answer in History', async () => {
const abort = jest.fn();
mockApi.chatCompletionStream.mockReturnValue({ abort });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
act(() => {
lastStreamCall()[3]('partial');
});
act(() => {
result.current.stopStreaming();
});
expect(abort).toHaveBeenCalledTimes(1);
expect(state().isStreaming).toBe(false);
expect(state().chatSessions[0].messages.at(-1)?.content).toBe('partial');
});
it('is a no-op when nothing is streaming', () => {
const { result } = renderHook(() => useLMStudio());
act(() => {
result.current.stopStreaming();
});
expect(state().chatSessions).toEqual([]);
});
});
-77
View File
@@ -1,77 +0,0 @@
import { ChatMessage } from '../store/useAppStore';
import { findLastAssistantId, shouldShowTypingIndicator } from '../utils/chatSelectors';
import { buildServerConfig, isConfigChanged } from '../utils/serverConfig';
import { isDarkTheme } from '../utils/theme';
const msg = (id: string, role: ChatMessage['role'], content = 'x'): ChatMessage => ({
id,
role,
content,
timestamp: 0,
});
describe('findLastAssistantId', () => {
it('returns the most recent assistant message', () => {
expect(findLastAssistantId([msg('1', 'assistant'), msg('2', 'user'), msg('3', 'assistant')])).toBe('3');
});
it('returns null when there is no assistant message', () => {
expect(findLastAssistantId([msg('1', 'user')])).toBeNull();
expect(findLastAssistantId([])).toBeNull();
});
});
describe('shouldShowTypingIndicator', () => {
it('is true only while streaming an assistant message with no text yet', () => {
expect(shouldShowTypingIndicator([msg('1', 'user'), msg('2', 'assistant', '')], true)).toBe(true);
});
it('is false when not streaming, when text arrived, when the last message is the user, or when empty', () => {
expect(shouldShowTypingIndicator([msg('2', 'assistant', '')], false)).toBe(false);
expect(shouldShowTypingIndicator([msg('2', 'assistant', 'hi')], true)).toBe(false);
expect(shouldShowTypingIndicator([msg('1', 'user', '')], true)).toBe(false);
expect(shouldShowTypingIndicator([], true)).toBe(false);
});
});
describe('buildServerConfig', () => {
it('trims the host and API key and parses the port', () => {
expect(buildServerConfig({ host: ' 10.0.0.1 ', port: '8080', apiKey: ' k ' })).toEqual({
host: '10.0.0.1',
port: 8080,
apiKey: 'k',
});
});
it('uses the defaults for blank host, invalid port and blank key', () => {
expect(buildServerConfig({ host: '', port: 'abc', apiKey: ' ' })).toEqual({
host: 'localhost',
port: 1234,
apiKey: undefined,
});
});
});
describe('isConfigChanged', () => {
const saved = { host: 'lan', port: 1234, apiKey: 'k' };
it('is false when the form matches the saved config', () => {
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: 'k' }, saved)).toBe(false);
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, { host: 'lan', port: 1234 })).toBe(false);
});
it('is true when any single field differs', () => {
expect(isConfigChanged({ host: 'other', port: '1234', apiKey: 'k' }, saved)).toBe(true);
expect(isConfigChanged({ host: 'lan', port: '99', apiKey: 'k' }, saved)).toBe(true);
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, saved)).toBe(true);
});
});
describe('isDarkTheme', () => {
it('honours an explicit preference and falls back to the system scheme', () => {
expect(isDarkTheme('dark', 'light')).toBe(true);
expect(isDarkTheme('light', 'dark')).toBe(false);
expect(isDarkTheme('system', 'dark')).toBe(true);
expect(isDarkTheme('system', 'light')).toBe(false);
});
});
-8
View File
@@ -1,5 +1,4 @@
import { ModelSelector } from '@/components/chat/ModelSelector'; import { ModelSelector } from '@/components/chat/ModelSelector';
import { useAutoConnect } from '@/hooks/useAutoConnect';
import { useThemeColor } from '@/hooks/useThemeColor'; import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore'; import { useAppStore } from '@/store/useAppStore';
import { Stack, useRouter } from 'expo-router'; import { Stack, useRouter } from 'expo-router';
@@ -11,7 +10,6 @@ export default function ChatLayout() {
const router = useRouter(); const router = useRouter();
const clearChat = useAppStore(state => state.clearChat); const clearChat = useAppStore(state => state.clearChat);
const colors = useThemeColor(); const colors = useThemeColor();
useAutoConnect();
const isLargeScreen = width > 768; const isLargeScreen = width > 768;
const containerStyle = [ const containerStyle = [
@@ -38,8 +36,6 @@ export default function ChatLayout() {
<View style={{ flexDirection: 'row', gap: 8 }}> <View style={{ flexDirection: 'row', gap: 8 }}>
<TouchableOpacity <TouchableOpacity
onPress={() => router.push('/chat/history')} onPress={() => router.push('/chat/history')}
accessibilityRole="button"
accessibilityLabel="Chat history"
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]} style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7} activeOpacity={0.7}
> >
@@ -47,8 +43,6 @@ export default function ChatLayout() {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={clearChat} onPress={clearChat}
accessibilityRole="button"
accessibilityLabel="New chat"
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]} style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7} activeOpacity={0.7}
> >
@@ -59,8 +53,6 @@ export default function ChatLayout() {
headerRight: () => ( headerRight: () => (
<TouchableOpacity <TouchableOpacity
onPress={() => router.push('/settings')} onPress={() => router.push('/settings')}
accessibilityRole="button"
accessibilityLabel="Settings"
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]} style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7} activeOpacity={0.7}
> >
+1 -10
View File
@@ -20,12 +20,7 @@ export default function HistoryScreen() {
return ( return (
<View style={[styles.container, { backgroundColor: colors.backgroundColor }]}> <View style={[styles.container, { backgroundColor: colors.backgroundColor }]}>
<View style={[styles.header, { borderBottomColor: colors.border, backgroundColor: colors.backgroundColor }]}> <View style={[styles.header, { borderBottomColor: colors.border, backgroundColor: colors.backgroundColor }]}>
<TouchableOpacity <TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
onPress={() => router.back()}
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ArrowLeft size={24} color={colors.text} /> <ArrowLeft size={24} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>Chat History</Text> <Text style={[styles.title, { color: colors.text }]}>Chat History</Text>
@@ -48,8 +43,6 @@ export default function HistoryScreen() {
{ backgroundColor: colors.cardBackground, borderColor: colors.border } { backgroundColor: colors.cardBackground, borderColor: colors.border }
]} ]}
onPress={() => handleLoadChat(item.id)} onPress={() => handleLoadChat(item.id)}
accessibilityRole="button"
accessibilityLabel={`Open chat: ${item.title}`}
> >
<View style={styles.chatInfo}> <View style={styles.chatInfo}>
<Text style={[styles.chatTitle, { color: colors.text }]} numberOfLines={1}> <Text style={[styles.chatTitle, { color: colors.text }]} numberOfLines={1}>
@@ -62,8 +55,6 @@ export default function HistoryScreen() {
<TouchableOpacity <TouchableOpacity
style={styles.deleteButton} style={styles.deleteButton}
onPress={() => deleteChatSession(item.id)} onPress={() => deleteChatSession(item.id)}
accessibilityRole="button"
accessibilityLabel={`Delete chat: ${item.title}`}
> >
<Trash2 size={20} color={colors.errorText} /> <Trash2 size={20} color={colors.errorText} />
</TouchableOpacity> </TouchableOpacity>
+176 -17
View File
@@ -1,12 +1,20 @@
import { ChatInput } from '@/components/chat/ChatInput'; import { ChatInput } from '@/components/chat/ChatInput';
import { EmptyState } from '@/components/chat/EmptyState'; import { MessageBubble } from '@/components/chat/MessageBubble';
import { MessageList } from '@/components/chat/MessageList'; import { TypingIndicator } from '@/components/chat/TypingIndicator';
import { OfflineBanner } from '@/components/chat/OfflineBanner';
import { useLMStudio } from '@/hooks/useLMStudio'; import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor'; import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore'; import { useAppStore } from '@/store/useAppStore';
import React, { useCallback } from 'react'; import { MessageSquare, Sparkles } from 'lucide-react-native';
import { KeyboardAvoidingView, Platform, StyleSheet } from 'react-native'; import React, { useCallback, useEffect, useRef } from 'react';
import { FlatList, KeyboardAvoidingView, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
const PROMPT_SUGGESTIONS = [
'Explain recursion in simple terms',
'Write a short poem about coding',
'What are the best practices for React Native?',
'Help me debug a JavaScript error',
];
export default function ChatScreen() { export default function ChatScreen() {
const colors = useThemeColor(); // Dynamic theme const colors = useThemeColor(); // Dynamic theme
@@ -17,6 +25,7 @@ export default function ChatScreen() {
const deleteMessage = useAppStore(state => state.deleteMessage); const deleteMessage = useAppStore(state => state.deleteMessage);
const editMessage = useAppStore(state => state.editMessage); const editMessage = useAppStore(state => state.editMessage);
const { sendMessage, stopStreaming, regenerateResponse, isStreaming, isConnected } = useLMStudio(); const { sendMessage, stopStreaming, regenerateResponse, isStreaming, isConnected } = useLMStudio();
const flatListRef = useRef<FlatList>(null);
// Find the editing message content // Find the editing message content
const editingMessage = editingMessageId const editingMessage = editingMessageId
@@ -28,35 +37,122 @@ export default function ChatScreen() {
editMessage(editingMessageId, text); editMessage(editingMessageId, text);
deleteMessagesAfter(editingMessageId); deleteMessagesAfter(editingMessageId);
setEditingMessageId(null); setEditingMessageId(null);
// The edited message is now the last one, so regenerating answers it.
regenerateResponse(); setTimeout(() => {
const store = useAppStore.getState();
const msgs = store.messages;
// Add system prompt before mapped messages
const { SYSTEM_PROMPT } = require('@/constants/SystemPrompt');
const apiMsgs = [
{ role: 'system', content: SYSTEM_PROMPT },
...msgs.map(m => ({ role: m.role, content: m.content }))
];
const modelId = store.activeModelId;
if (!modelId) return;
const assistantId = store.addMessage({ role: 'assistant', content: '' });
store.setIsStreaming(true);
const { chatCompletionStream } = require('@/services/lmStudioApi');
chatCompletionStream(
store.serverConfig,
modelId,
apiMsgs,
(chunk: string) => store.appendToMessage(assistantId, chunk),
() => { store.setIsStreaming(false); },
(error: Error) => {
store.appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
store.setIsStreaming(false);
},
);
}, 100);
} else { } else {
sendMessage(text); sendMessage(text);
} }
}, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, regenerateResponse, sendMessage]); }, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, sendMessage]);
const handleEdit = useCallback((id: string) => setEditingMessageId(id), [setEditingMessageId]); const handleEdit = useCallback((id: string) => setEditingMessageId(id), [setEditingMessageId]);
const handleCancelEdit = useCallback(() => setEditingMessageId(null), [setEditingMessageId]); const handleCancelEdit = useCallback(() => setEditingMessageId(null), [setEditingMessageId]);
const handleDelete = useCallback((id: string) => deleteMessage(id), [deleteMessage]); const handleDelete = useCallback((id: string) => deleteMessage(id), [deleteMessage]);
const lastAssistantId = messages.length > 0
? [...messages].reverse().find(m => m.role === 'assistant')?.id
: null;
useEffect(() => {
if (messages.length > 0 && flatListRef.current) {
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: !isStreaming });
}, 100);
}
}, [messages.length, messages[messages.length - 1]?.content?.length, isStreaming]);
const showTypingIndicator = isStreaming &&
messages.length > 0 &&
messages[messages.length - 1]?.role === 'assistant' &&
messages[messages.length - 1]?.content === '';
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
style={[styles.container, { backgroundColor: colors.backgroundColor }]} style={[styles.container, { backgroundColor: colors.backgroundColor }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0} keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
> >
{!isConnected && <OfflineBanner />} {!isConnected && (
<View style={[styles.banner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.bannerBg }]}>
<Text style={[styles.bannerText, { color: colors.bannerText }]}>
⚠️ Server offline. Check Settings → Server Connection.
</Text>
</View>
)}
{messages.length === 0 ? ( {messages.length === 0 ? (
<EmptyState onSelectPrompt={sendMessage} /> <View style={[styles.emptyState, { backgroundColor: colors.backgroundColor }]}>
<View style={[styles.emptyIconWrap, { backgroundColor: colors.cardBackground, shadowColor: colors.shadow }]}>
<MessageSquare size={40} color={colors.textMuted} />
</View>
<Text style={[styles.emptyTitle, { color: colors.text }]}>Start a conversation</Text>
<Text style={[styles.emptySubtitle, { color: colors.textMuted }]}>
Send a message to begin chatting with your local AI model.
</Text>
<View style={styles.suggestionsWrap}>
{PROMPT_SUGGESTIONS.map((prompt, i) => (
<TouchableOpacity
key={i}
style={[styles.suggestionChip, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}
onPress={() => sendMessage(prompt)}
activeOpacity={0.7}
>
<Sparkles size={14} color={colors.textMuted} />
<Text style={[styles.suggestionText, { color: colors.text }]} numberOfLines={1}>{prompt}</Text>
</TouchableOpacity>
))}
</View>
</View>
) : ( ) : (
<MessageList <>
messages={messages} <FlatList
isStreaming={isStreaming} ref={flatListRef}
onEdit={handleEdit} data={messages}
onDelete={handleDelete} keyExtractor={(item) => item.id}
onRegenerate={regenerateResponse} renderItem={({ item, index }) => (
/> <Animated.View entering={FadeInDown.duration(300).delay(index > messages.length - 4 ? 100 : 0)}>
<MessageBubble
message={item}
onEdit={() => handleEdit(item.id)}
onRegenerate={regenerateResponse}
onDelete={() => handleDelete(item.id)}
isStreaming={isStreaming}
isLastAssistant={item.id === lastAssistantId}
/>
</Animated.View>
)}
contentContainerStyle={styles.listContent}
/>
{showTypingIndicator && <TypingIndicator />}
</>
)} )}
<ChatInput <ChatInput
@@ -74,4 +170,67 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
listContent: {
paddingTop: 16,
paddingBottom: 24,
},
banner: {
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: 1,
},
bannerText: {
fontSize: 13,
textAlign: 'center',
fontWeight: '500',
},
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
paddingBottom: 80,
},
emptyIconWrap: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
emptyTitle: {
fontSize: 22,
fontWeight: '700',
marginBottom: 8,
},
emptySubtitle: {
fontSize: 15,
textAlign: 'center',
lineHeight: 22,
marginBottom: 32,
},
suggestionsWrap: {
width: '100%',
maxWidth: 400,
gap: 12,
},
suggestionChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
},
suggestionText: {
fontSize: 15,
flex: 1,
fontWeight: '500',
},
}); });
+338 -8
View File
@@ -1,14 +1,76 @@
import { AboutCard } from '@/components/settings/AboutCard'; import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
import { AppearanceCard } from '@/components/settings/AppearanceCard'; import { useLMStudio } from '@/hooks/useLMStudio';
import { ServerConnectionCard } from '@/components/settings/ServerConnectionCard';
import { useThemeColor } from '@/hooks/useThemeColor'; import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react'; import * as api from '@/services/lmStudioApi';
import { ScrollView, StyleSheet, useWindowDimensions } from 'react-native'; import { ServerConfig } from '@/services/types';
import { useAppStore } from '@/store/useAppStore';
import { Info, MonitorCog, Moon, Network } from 'lucide-react-native';
import React, { useState } from 'react';
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
useWindowDimensions,
View,
} from 'react-native';
export default function SettingsScreen() { export default function SettingsScreen() {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const colors = useThemeColor(); // Get current theme colors const colors = useThemeColor(); // Get current theme colors
// Global State
const themePreference = useAppStore(state => state.theme);
const setTheme = useAppStore(state => state.setTheme);
const serverConfig = useAppStore(state => state.serverConfig);
const setServerConfig = useAppStore(state => state.setServerConfig);
const isConnected = useAppStore(state => state.isConnected);
const setIsConnected = useAppStore(state => state.setIsConnected);
const { fetchModels } = useLMStudio();
// Local State
const [host, setHost] = useState(serverConfig.host);
const [port, setPort] = useState(serverConfig.port.toString());
const [apiKey, setApiKey] = useState(serverConfig.apiKey ?? '');
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<boolean | null>(null);
const isLargeScreen = width > 768; const isLargeScreen = width > 768;
const isDarkMode = themePreference === 'dark' || (themePreference === 'system' && colors.backgroundColor === '#09090B'); // Hacky check but robust given our Colors.ts
const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark');
const currentConfig: ServerConfig = {
host: host.trim() || 'localhost',
port: parseInt(port, 10) || 1234,
apiKey: apiKey.trim() || undefined,
};
const handleSaveConfig = () => {
setServerConfig(currentConfig);
setTestResult(null);
};
const handleTestConnection = async () => {
handleSaveConfig();
setIsTesting(true);
setTestResult(null);
const ok = await api.testConnection(currentConfig);
setTestResult(ok);
setIsConnected(ok);
if (ok) {
await fetchModels();
}
setIsTesting(false);
};
const configChanged =
host !== serverConfig.host ||
port !== serverConfig.port.toString() ||
apiKey !== (serverConfig.apiKey ?? '');
return ( return (
<ScrollView <ScrollView
@@ -18,9 +80,151 @@ export default function SettingsScreen() {
isLargeScreen && styles.desktopContent, isLargeScreen && styles.desktopContent,
]} ]}
> >
<ServerConnectionCard /> {/* Server Connection Section */}
<AppearanceCard /> <View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<AboutCard /> <Text style={[styles.sectionLabel, { color: colors.textMuted }]}>SERVER CONNECTION</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
<View style={styles.cardHeader}>
<View style={styles.cardHeaderTitle}>
<Network size={20} color={colors.text} style={styles.headerIcon} />
<Text style={[styles.cardTitle, { color: colors.text }]}>LM Studio API</Text>
</View>
<ConnectionBadge isConnected={isConnected} />
</View>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>Host IP / Address</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
]}
value={host}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>Port Number</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
]}
value={port}
onChangeText={setPort}
placeholder="1234"
placeholderTextColor={colors.textMuted}
keyboardType="numeric"
/>
</View>
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>API Key (optional)</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
]}
value={apiKey}
onChangeText={setApiKey}
placeholder="Leave empty for local"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
/>
</View>
<View style={styles.buttonRow}>
{configChanged && (
<TouchableOpacity
style={[styles.saveButton, { borderColor: colors.border, backgroundColor: colors.cardBackground }]}
onPress={handleSaveConfig}
>
<Text style={[styles.saveButtonText, { color: colors.text }]}>Save Changes</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[
styles.testButton,
{ backgroundColor: colors.tint },
isTesting && styles.testButtonDisabled
]}
onPress={handleTestConnection}
disabled={isTesting}
>
{isTesting ? (
<ActivityIndicator size="small" color={colors.backgroundColor} />
) : (
<Text style={[styles.testButtonText, { color: colors.backgroundColor }]}>Test Connection</Text>
)}
</TouchableOpacity>
</View>
{testResult !== null && (
<View style={[
styles.resultBox,
{ backgroundColor: testResult ? colors.successBg : colors.errorBg }
]}>
<Text style={[
styles.resultText,
{ color: testResult ? colors.successText : colors.errorText }
]}>
{testResult
? 'Connection successful. API is ready.'
: 'Connection failed. Verify host and port.'}
</Text>
</View>
)}
</View>
</View>
{/* Appearance Section */}
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>APPEARANCE</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
<View style={styles.row}>
<View style={styles.rowIconWrap}>
<Moon size={20} color={colors.text} />
</View>
<View style={styles.rowText}>
<Text style={[styles.rowTitle, { color: colors.text }]}>Dark Mode</Text>
<Text style={[styles.rowSubtitle, { color: colors.textMuted }]}>Toggle sleek dark theme</Text>
</View>
<Switch
value={isDarkMode}
onValueChange={toggleTheme}
trackColor={{ false: '#E2E8F0', true: colors.tint }}
thumbColor={'#FFFFFF'}
/>
</View>
</View>
</View>
{/* About Section */}
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>ABOUT</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
<View style={styles.rowGroup}>
<Info size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.text }]}>LocalMind - Cross Platform Version</Text>
</View>
<View style={[styles.rowGroup, { marginTop: 8 }]}>
<MonitorCog size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.textMuted, fontSize: 13 }]}>
Connected via OpenAPI `/v1/chat/completions` specs
</Text>
</View>
</View>
</View>
</ScrollView> </ScrollView>
); );
} }
@@ -37,4 +241,130 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
paddingTop: 40, paddingTop: 40,
}, },
section: {
marginBottom: 24,
},
desktopSection: {
width: '100%',
maxWidth: 600,
},
sectionLabel: {
fontSize: 13,
fontWeight: '600',
marginLeft: 16,
marginBottom: 8,
letterSpacing: 0.5,
},
card: {
borderRadius: 16,
padding: 16,
borderWidth: 1,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
},
cardHeaderTitle: {
flexDirection: 'row',
alignItems: 'center',
},
headerIcon: {
marginRight: 8,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
},
divider: {
height: 1,
width: '100%',
marginBottom: 16,
},
inputGroup: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
textInput: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
},
buttonRow: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
saveButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
borderWidth: 1,
},
saveButtonText: {
fontSize: 15,
fontWeight: '600',
},
testButton: {
flex: 2,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
testButtonDisabled: {
opacity: 0.7,
},
testButtonText: {
fontSize: 15,
fontWeight: '600',
},
resultBox: {
marginTop: 16,
borderRadius: 12,
padding: 14,
},
resultText: {
fontSize: 14,
textAlign: 'center',
fontWeight: '500',
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 4,
},
rowIconWrap: {
marginRight: 12,
},
rowText: {
flex: 1,
},
rowTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
rowSubtitle: {
fontSize: 14,
},
rowGroup: {
flexDirection: 'row',
alignItems: 'center',
},
infoIcon: {
marginRight: 10,
},
infoText: {
fontSize: 15,
lineHeight: 22,
},
}); });
+46
View File
@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420" viewBox="0 0 1200 420" role="img" aria-labelledby="title desc">
<title id="title">LocalMind</title>
<desc id="desc">LocalMind: a phone chat screen marked Connected talks over the local network to LM Studio running on your computer at port 1234, path /v1.</desc>
<rect width="1200" height="420" rx="24" fill="#09090B"/>
<g id="title-block" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
<text x="64" y="170" font-size="64" font-weight="700" fill="#FAFAFA">LocalMind</text>
<text x="64" y="226" font-size="28" fill="#FAFAFA">Chat with your LM Studio models</text>
<text x="64" y="264" font-size="28" fill="#FAFAFA">from a phone or browser on your LAN.</text>
<text x="64" y="322" font-size="20" fill="#A1A1AA" font-family="ui-monospace, 'SF Mono', Consolas, monospace">React Native 0.81 · Expo 54 · TypeScript</text>
</g>
<g id="phone" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
<rect x="644" y="44" width="216" height="332" rx="34" fill="#18181B" stroke="#3F3F46" stroke-width="3"/>
<rect x="722" y="58" width="60" height="8" rx="4" fill="#3F3F46"/>
<rect x="662" y="82" width="134" height="32" rx="16" fill="#052E16"/>
<circle cx="680" cy="98" r="5" fill="#BBF7D0"/>
<text x="692" y="104" font-size="18" fill="#BBF7D0">Connected</text>
<rect x="696" y="132" width="146" height="40" rx="16" fill="#FAFAFA"/>
<text x="769" y="158" font-size="20" fill="#09090B" text-anchor="middle">Explain SSE</text>
<rect x="662" y="188" width="164" height="104" rx="16" fill="#27272A"/>
<rect x="680" y="208" width="128" height="10" rx="5" fill="#A1A1AA"/>
<rect x="680" y="232" width="112" height="10" rx="5" fill="#A1A1AA"/>
<rect x="680" y="256" width="72" height="10" rx="5" fill="#A1A1AA"/>
<rect x="760" y="252" width="10" height="18" rx="2" fill="#FAFAFA"/>
<rect x="662" y="314" width="180" height="40" rx="20" fill="#09090B" stroke="#3F3F46" stroke-width="2"/>
<text x="682" y="340" font-size="18" fill="#A1A1AA">Message</text>
<circle cx="822" cy="334" r="13" fill="#FAFAFA"/>
<path d="M822 340 v-12 M816 333 l6 -6 l6 6" fill="none" stroke="#09090B" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<g id="lan-link" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
<path d="M872 206 h80" stroke="#A1A1AA" stroke-width="2" stroke-dasharray="8 6"/>
<text x="912" y="190" font-size="18" fill="#A1A1AA" text-anchor="middle">LAN</text>
</g>
<g id="computer" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
<rect x="960" y="140" width="176" height="124" rx="12" fill="#18181B" stroke="#3F3F46" stroke-width="3"/>
<text x="1048" y="194" font-size="20" font-weight="700" fill="#FAFAFA" text-anchor="middle">LM Studio</text>
<text x="1048" y="228" font-size="20" fill="#A1A1AA" text-anchor="middle" font-family="ui-monospace, 'SF Mono', Consolas, monospace">:1234/v1</text>
<rect x="1032" y="264" width="32" height="24" fill="#3F3F46"/>
<rect x="1004" y="288" width="88" height="10" rx="4" fill="#3F3F46"/>
<text x="1048" y="334" font-size="18" fill="#A1A1AA" text-anchor="middle">your machine</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+1 -12
View File
@@ -65,12 +65,7 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
{isEditing && ( {isEditing && (
<View style={[styles.editBanner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.border }]}> <View style={[styles.editBanner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.border }]}>
<Text style={[styles.editBannerText, { color: colors.bannerText }]}>Editing message</Text> <Text style={[styles.editBannerText, { color: colors.bannerText }]}>Editing message</Text>
<TouchableOpacity <TouchableOpacity onPress={handleCancelEdit} style={styles.editCancelBtn}>
onPress={handleCancelEdit}
style={styles.editCancelBtn}
accessibilityRole="button"
accessibilityLabel="Cancel editing"
>
<X size={16} color={colors.textMuted} /> <X size={16} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -85,7 +80,6 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
<TextInput <TextInput
style={[styles.input, { color: colors.text }]} style={[styles.input, { color: colors.text }]}
placeholder="Send a message..." placeholder="Send a message..."
accessibilityLabel="Message"
placeholderTextColor={colors.textMuted} placeholderTextColor={colors.textMuted}
value={text} value={text}
onChangeText={setText} onChangeText={setText}
@@ -100,8 +94,6 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
style={[styles.stopButton, { backgroundColor: colors.errorText }]} style={[styles.stopButton, { backgroundColor: colors.errorText }]}
onPress={handleStop} onPress={handleStop}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Stop generating"
> >
<Square size={16} color={colors.backgroundColor} /> <Square size={16} color={colors.backgroundColor} />
</TouchableOpacity> </TouchableOpacity>
@@ -114,9 +106,6 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
onPress={handleSend} onPress={handleSend}
disabled={!text.trim()} disabled={!text.trim()}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Send message"
accessibilityState={{ disabled: !text.trim() }}
> >
<Send size={18} color={text.trim() ? colors.backgroundColor : colors.textMuted} /> <Send size={18} color={text.trim() ? colors.backgroundColor : colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
-77
View File
@@ -1,77 +0,0 @@
import { Check, Copy } from 'lucide-react-native';
import React from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface CodeBlockProps {
language: string;
content: string;
copied: boolean;
onCopy: (text: string) => void;
}
export function CodeBlock({ language, content, copied, onCopy }: CodeBlockProps) {
const CopyIcon = copied ? Check : Copy;
return (
<View style={styles.codeBlockWrapper}>
<View style={styles.codeBlockHeader}>
<Text style={styles.codeBlockLanguage}>{language}</Text>
<TouchableOpacity
onPress={() => onCopy(content)}
style={styles.copyButton}
accessibilityRole="button"
>
<CopyIcon size={14} color="#A0A0A0" />
<Text style={styles.copyText}>{copied ? 'Copied!' : 'Copy'}</Text>
</TouchableOpacity>
</View>
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
codeBlockWrapper: {
backgroundColor: '#1E1E1E',
borderRadius: 8,
marginVertical: 8,
overflow: 'hidden',
width: '100%',
},
codeBlockHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#2D2D2D',
paddingHorizontal: 12,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#404040',
},
codeBlockLanguage: {
color: '#A0A0A0',
fontSize: 12,
fontFamily: 'monospace',
textTransform: 'lowercase',
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
padding: 4,
},
copyText: {
color: '#A0A0A0',
fontSize: 12,
},
codeScrollView: {
padding: 12,
},
codeText: {
color: '#D4D4D4',
fontSize: 13,
fontFamily: 'monospace',
},
});
-99
View File
@@ -1,99 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { MessageSquare, Sparkles } from 'lucide-react-native';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
const PROMPT_SUGGESTIONS = [
'Explain recursion in simple terms',
'Write a short poem about coding',
'What are the best practices for React Native?',
'Help me debug a JavaScript error',
];
interface EmptyStateProps {
onSelectPrompt: (prompt: string) => void;
}
export function EmptyState({ onSelectPrompt }: EmptyStateProps) {
const colors = useThemeColor();
return (
<View style={[styles.emptyState, { backgroundColor: colors.backgroundColor }]}>
<View style={[styles.emptyIconWrap, { backgroundColor: colors.cardBackground, shadowColor: colors.shadow }]}>
<MessageSquare size={40} color={colors.textMuted} />
</View>
<Text style={[styles.emptyTitle, { color: colors.text }]}>Start a conversation</Text>
<Text style={[styles.emptySubtitle, { color: colors.textMuted }]}>
Send a message to begin chatting with your local AI model.
</Text>
<View style={styles.suggestionsWrap}>
{PROMPT_SUGGESTIONS.map((prompt) => (
<TouchableOpacity
key={prompt}
style={[styles.suggestionChip, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}
onPress={() => onSelectPrompt(prompt)}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={prompt}
>
<Sparkles size={14} color={colors.textMuted} />
<Text style={[styles.suggestionText, { color: colors.text }]} numberOfLines={1}>{prompt}</Text>
</TouchableOpacity>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
paddingBottom: 80,
},
emptyIconWrap: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
emptyTitle: {
fontSize: 22,
fontWeight: '700',
marginBottom: 8,
},
emptySubtitle: {
fontSize: 15,
textAlign: 'center',
lineHeight: 22,
marginBottom: 32,
},
suggestionsWrap: {
width: '100%',
maxWidth: 400,
gap: 12,
},
suggestionChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
},
suggestionText: {
fontSize: 15,
flex: 1,
fontWeight: '500',
},
});
+4 -4
View File
@@ -50,24 +50,24 @@ export function MessageActions({
return ( return (
<View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}> <View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}>
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Copy message"> <TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6}>
<Copy size={16} color={colors.textMuted} /> <Copy size={16} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
{isUser && onEdit && ( {isUser && onEdit && (
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Edit message"> <TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6}>
<Pencil size={16} color={colors.textMuted} /> <Pencil size={16} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
)} )}
{!isUser && onRegenerate && ( {!isUser && onRegenerate && (
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Regenerate response"> <TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6}>
<RefreshCw size={16} color={colors.textMuted} /> <RefreshCw size={16} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
)} )}
{onDelete && ( {onDelete && (
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Delete message"> <TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6}>
<Trash2 size={16} color={colors.textMuted} /> <Trash2 size={16} color={colors.textMuted} />
</TouchableOpacity> </TouchableOpacity>
)} )}
+260 -62
View File
@@ -1,10 +1,11 @@
import { useThemeColor } from '@/hooks/useThemeColor'; import { useThemeColor } from '@/hooks/useThemeColor';
import { ChatMessage } from '@/store/useAppStore'; import { ChatMessage } from '@/store/useAppStore';
import { Bot, User } from 'lucide-react-native'; import Markdown from '@ronradtke/react-native-markdown-display';
import React, { useState } from 'react'; import * as Clipboard from 'expo-clipboard';
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'; import { Bot, Check, Copy, User } from 'lucide-react-native';
import React, { useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
import { MessageActions } from './MessageActions'; import { MessageActions } from './MessageActions';
import { MessageContent } from './MessageContent';
interface MessageBubbleProps { interface MessageBubbleProps {
message: ChatMessage; message: ChatMessage;
@@ -15,58 +16,6 @@ interface MessageBubbleProps {
isLastAssistant?: boolean; isLastAssistant?: boolean;
} }
function Avatar({ isUser }: { isUser: boolean }) {
const colors = useThemeColor();
const Icon = isUser ? User : Bot;
return (
<View style={[styles.avatar, { backgroundColor: isUser ? colors.tint : colors.border }]}>
<Icon size={16} color={isUser ? colors.backgroundColor : colors.text} />
</View>
);
}
function BubbleRow({ message, showingStreaming }: { message: ChatMessage; showingStreaming: boolean }) {
const { width } = useWindowDimensions();
const colors = useThemeColor();
const isUser = message.role === 'user';
const maxWidth = width > 768 ? '70%' : '85%';
return (
<View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}>
{!isUser && <Avatar isUser={false} />}
<View style={[
styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleAssistant,
{ maxWidth },
{ backgroundColor: isUser ? colors.messageUserBg : colors.messageAssistantBg },
!isUser && { borderWidth: 1, borderColor: colors.border }
]}>
<MessageContent message={message} showingStreaming={showingStreaming} />
</View>
{isUser && <Avatar isUser />}
</View>
);
}
/** Timestamp + edited badge. */
function BubbleMeta({ message }: { message: ChatMessage }) {
const colors = useThemeColor();
const isUser = message.role === 'user';
return (
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
{!isUser && <View style={{ width: 32 }} />}
<Text style={[styles.timestamp, { color: colors.textMuted }]}>
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{message.isEdited ? ' · edited' : ''}
</Text>
</View>
);
}
export function MessageBubble({ export function MessageBubble({
message, message,
onEdit, onEdit,
@@ -75,21 +24,217 @@ export function MessageBubble({
isStreaming, isStreaming,
isLastAssistant, isLastAssistant,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const { width } = useWindowDimensions();
const colors = useThemeColor();
const isLargeScreen = width > 768;
const isUser = message.role === 'user'; const isUser = message.role === 'user';
const [showActions, setShowActions] = useState(false); const [showActions, setShowActions] = useState(false);
const [copiedText, setCopiedText] = useState<string | null>(null);
const showingStreaming = !!isStreaming && !!isLastAssistant; const handleCopy = async (text: string) => {
const actionsVisible = (!isUser || showActions) && message.content.length > 0; await Clipboard.setStringAsync(text);
setCopiedText(text);
setTimeout(() => setCopiedText(null), 2000);
};
const showingStreaming = isStreaming && isLastAssistant;
const shouldShowActions = !isUser || showActions;
// UseMemo for dynamic colors
const markdownStyles = useMemo(() => ({
body: {
fontSize: 15,
lineHeight: 22,
color: colors.messageAssistantText,
},
strong: {
fontWeight: '700',
color: colors.text,
},
em: {
fontStyle: 'italic',
},
code_inline: {
backgroundColor: colors.backgroundColor,
color: colors.text,
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 4,
fontSize: 13,
fontFamily: 'monospace',
},
fence: {
backgroundColor: colors.inputBg,
borderColor: colors.border,
borderWidth: 1,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
overflow: 'hidden',
},
code_block: {
backgroundColor: colors.inputBg,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: colors.border,
paddingLeft: 12,
marginVertical: 4,
color: colors.textMuted,
},
bullet_list: {
marginVertical: 4,
},
ordered_list: {
marginVertical: 4,
},
list_item: {
marginVertical: 2,
color: colors.messageAssistantText,
},
heading1: {
fontSize: 22,
fontWeight: '700',
marginVertical: 8,
color: colors.text,
},
heading2: {
fontSize: 19,
fontWeight: '700',
marginVertical: 6,
color: colors.text,
},
heading3: {
fontSize: 17,
fontWeight: '600',
marginVertical: 4,
color: colors.text,
},
link: {
color: '#3B82F6',
textDecorationLine: 'underline',
},
hr: {
backgroundColor: colors.border,
height: 1,
marginVertical: 12,
},
paragraph: {
marginVertical: 2,
},
} as any), [colors]); // using 'as any' to satisfy internal Markdown types if needed
const markdownRules = useMemo(() => ({
fence: (node: any, children: any, parent: any, parentStyles: any) => {
const content = node.content || '';
const language = node.sourceInfo || 'code';
return (
<View key={node.key} style={styles.codeBlockWrapper}>
<View style={styles.codeBlockHeader}>
<Text style={styles.codeBlockLanguage}>{language}</Text>
<TouchableOpacity onPress={() => handleCopy(content)} style={styles.copyButton}>
{copiedText === content ? <Check size={14} color="#A0A0A0" /> : <Copy size={14} color="#A0A0A0" />}
<Text style={styles.copyText}>{copiedText === content ? 'Copied!' : 'Copy'}</Text>
</TouchableOpacity>
</View>
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
);
},
code_block: (node: any, children: any, parent: any, parentStyles: any) => {
const content = node.content || '';
return (
<View key={node.key} style={styles.codeBlockWrapper}>
<View style={styles.codeBlockHeader}>
<Text style={styles.codeBlockLanguage}>code</Text>
<TouchableOpacity onPress={() => handleCopy(content)} style={styles.copyButton}>
{copiedText === content ? <Check size={14} color="#A0A0A0" /> : <Copy size={14} color="#A0A0A0" />}
<Text style={styles.copyText}>{copiedText === content ? 'Copied!' : 'Copy'}</Text>
</TouchableOpacity>
</View>
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
);
}
}), [copiedText]);
const renderContent = () => {
if (isUser) {
return (
<Text style={[styles.text, { color: colors.messageUserText }]}>
{message.content}
</Text>
);
}
if (!message.content && showingStreaming) {
return null; // TypingIndicator handled externally
}
return (
<Markdown style={markdownStyles} rules={markdownRules}>
{message.content}
</Markdown>
);
};
return ( return (
<View <View
onTouchStart={() => isUser && setShowActions(true)} onTouchStart={() => isUser && setShowActions(true)}
style={styles.outerWrap} style={styles.outerWrap}
> >
<BubbleRow message={message} showingStreaming={showingStreaming} /> <View style={[
<BubbleMeta message={message} /> styles.container,
isUser ? styles.containerUser : styles.containerAssistant,
]}>
{/* Avatar */}
{!isUser && (
<View style={[styles.avatarAssistant, { backgroundColor: colors.border }]}>
<Bot size={16} color={colors.text} />
</View>
)}
{actionsVisible && ( <View style={[
styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleAssistant,
{ maxWidth: isLargeScreen ? '70%' : '85%' },
{ backgroundColor: isUser ? colors.messageUserBg : colors.messageAssistantBg },
!isUser && { borderWidth: 1, borderColor: colors.border }
]}>
{renderContent()}
</View>
{isUser && (
<View style={[styles.avatarUser, { backgroundColor: colors.tint }]}>
<User size={16} color={colors.backgroundColor} />
</View>
)}
</View>
{/* Timestamp + edited badge */}
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
{!isUser && <View style={{ width: 32 }} />}
<Text style={[styles.timestamp, { color: colors.textMuted }]}>
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{message.isEdited ? ' · edited' : ''}
</Text>
</View>
{/* Action buttons */}
{(shouldShowActions || !isUser) && message.content.length > 0 && (
<MessageActions <MessageActions
role={message.role} role={message.role}
content={message.content} content={message.content}
@@ -121,7 +266,15 @@ const styles = StyleSheet.create({
containerAssistant: { containerAssistant: {
justifyContent: 'flex-start', justifyContent: 'flex-start',
}, },
avatar: { avatarAssistant: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 2,
},
avatarUser: {
width: 32, width: 32,
height: 32, height: 32,
borderRadius: 16, borderRadius: 16,
@@ -142,6 +295,10 @@ const styles = StyleSheet.create({
bubbleAssistant: { bubbleAssistant: {
borderBottomLeftRadius: 6, borderBottomLeftRadius: 6,
}, },
text: {
fontSize: 15,
lineHeight: 22,
},
metaRow: { metaRow: {
flexDirection: 'row', flexDirection: 'row',
paddingHorizontal: 16, paddingHorizontal: 16,
@@ -159,4 +316,45 @@ const styles = StyleSheet.create({
fontSize: 11, fontSize: 11,
fontWeight: '500', fontWeight: '500',
}, },
codeBlockWrapper: {
backgroundColor: '#1E1E1E',
borderRadius: 8,
marginVertical: 8,
overflow: 'hidden',
width: '100%',
},
codeBlockHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#2D2D2D',
paddingHorizontal: 12,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#404040',
},
codeBlockLanguage: {
color: '#A0A0A0',
fontSize: 12,
fontFamily: 'monospace',
textTransform: 'lowercase',
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
padding: 4,
},
copyText: {
color: '#A0A0A0',
fontSize: 12,
},
codeScrollView: {
padding: 12,
},
codeText: {
color: '#D4D4D4',
fontSize: 13,
fontFamily: 'monospace',
},
}); });
-73
View File
@@ -1,73 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { ChatMessage } from '@/store/useAppStore';
import Markdown from '@ronradtke/react-native-markdown-display';
import * as Clipboard from 'expo-clipboard';
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text } from 'react-native';
import { CodeBlock } from './CodeBlock';
import { createMarkdownStyles } from './markdownStyles';
interface MessageContentProps {
message: ChatMessage;
/** True while this message is the answer currently being streamed. */
showingStreaming: boolean;
}
export function MessageContent({ message, showingStreaming }: MessageContentProps) {
const colors = useThemeColor();
const [copiedText, setCopiedText] = useState<string | null>(null);
const handleCopy = async (text: string) => {
await Clipboard.setStringAsync(text);
setCopiedText(text);
setTimeout(() => setCopiedText(null), 2000);
};
const markdownStyles = useMemo(() => createMarkdownStyles(colors), [colors]);
const markdownRules = useMemo(() => ({
fence: (node: any) => (
<CodeBlock
key={node.key}
language={node.sourceInfo || 'code'}
content={node.content || ''}
copied={copiedText === (node.content || '')}
onCopy={handleCopy}
/>
),
code_block: (node: any) => (
<CodeBlock
key={node.key}
language="code"
content={node.content || ''}
copied={copiedText === (node.content || '')}
onCopy={handleCopy}
/>
),
}), [copiedText]);
if (message.role === 'user') {
return (
<Text style={[styles.text, { color: colors.messageUserText }]}>
{message.content}
</Text>
);
}
if (!message.content && showingStreaming) {
return null; // TypingIndicator handled externally
}
return (
<Markdown style={markdownStyles} rules={markdownRules}>
{message.content}
</Markdown>
);
}
const styles = StyleSheet.create({
text: {
fontSize: 15,
lineHeight: 22,
},
});
-94
View File
@@ -1,94 +0,0 @@
import { MessageBubble } from '@/components/chat/MessageBubble';
import { TypingIndicator } from '@/components/chat/TypingIndicator';
import { ChatMessage } from '@/store/useAppStore';
import { findLastAssistantId, shouldShowTypingIndicator } from '@/utils/chatSelectors';
import React, { useEffect, useRef } from 'react';
import { FlatList, StyleSheet } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
interface MessageListProps {
messages: ChatMessage[];
isStreaming: boolean;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onRegenerate: () => void;
}
interface MessageRowProps {
message: ChatMessage;
delayed: boolean;
isStreaming: boolean;
isLastAssistant: boolean;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onRegenerate: () => void;
}
// Memoized so that a streamed token only re-renders (and re-parses the Markdown of)
// the message that changed, not the whole conversation.
const MessageRow = React.memo(function MessageRow({
message,
delayed,
isStreaming,
isLastAssistant,
onEdit,
onDelete,
onRegenerate,
}: MessageRowProps) {
return (
<Animated.View entering={FadeInDown.duration(300).delay(delayed ? 100 : 0)}>
<MessageBubble
message={message}
onEdit={() => onEdit(message.id)}
onRegenerate={onRegenerate}
onDelete={() => onDelete(message.id)}
isStreaming={isStreaming}
isLastAssistant={isLastAssistant}
/>
</Animated.View>
);
});
export function MessageList({ messages, isStreaming, onEdit, onDelete, onRegenerate }: MessageListProps) {
const flatListRef = useRef<FlatList>(null);
const lastAssistantId = findLastAssistantId(messages);
const lastContentLength = messages[messages.length - 1]?.content?.length;
useEffect(() => {
if (messages.length > 0 && flatListRef.current) {
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: !isStreaming });
}, 100);
}
}, [messages.length, lastContentLength, isStreaming]);
return (
<>
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item, index }) => (
<MessageRow
message={item}
delayed={index > messages.length - 4}
isStreaming={isStreaming}
isLastAssistant={item.id === lastAssistantId}
onEdit={onEdit}
onDelete={onDelete}
onRegenerate={onRegenerate}
/>
)}
contentContainerStyle={styles.listContent}
/>
{shouldShowTypingIndicator(messages, isStreaming) && <TypingIndicator />}
</>
);
}
const styles = StyleSheet.create({
listContent: {
paddingTop: 16,
paddingBottom: 24,
},
});
-7
View File
@@ -42,8 +42,6 @@ export function ModelSelector() {
style={[styles.headerButton, { backgroundColor: colors.cardBackground }]} style={[styles.headerButton, { backgroundColor: colors.cardBackground }]}
onPress={() => setModalVisible(true)} onPress={() => setModalVisible(true)}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={`Model: ${activeModelName}. Tap to change`}
> >
<ConnectionBadge isConnected={isConnected} compact /> <ConnectionBadge isConnected={isConnected} compact />
<Text style={[styles.headerText, { color: colors.text }]} numberOfLines={1}> <Text style={[styles.headerText, { color: colors.text }]} numberOfLines={1}>
@@ -72,8 +70,6 @@ export function ModelSelector() {
onPress={handleRefresh} onPress={handleRefresh}
style={[styles.refreshButton, { backgroundColor: colors.cardBackground }]} style={[styles.refreshButton, { backgroundColor: colors.cardBackground }]}
disabled={isRefreshing} disabled={isRefreshing}
accessibilityRole="button"
accessibilityLabel="Refresh models"
> >
{isRefreshing ? ( {isRefreshing ? (
<ActivityIndicator size="small" color={colors.textMuted} /> <ActivityIndicator size="small" color={colors.textMuted} />
@@ -112,9 +108,6 @@ export function ModelSelector() {
isSelected && { borderColor: colors.tint } isSelected && { borderColor: colors.tint }
]} ]}
onPress={() => handleSelect(item.id)} onPress={() => handleSelect(item.id)}
accessibilityRole="button"
accessibilityLabel={item.id}
accessibilityState={{ selected: isSelected }}
> >
<View style={styles.itemContent}> <View style={styles.itemContent}>
<Text style={[styles.itemName, { color: colors.text }]}> <Text style={[styles.itemName, { color: colors.text }]}>
-31
View File
@@ -1,31 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
export function OfflineBanner() {
const colors = useThemeColor();
return (
<View
accessibilityRole="alert"
style={[styles.banner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.bannerBg }]}
>
<Text style={[styles.bannerText, { color: colors.bannerText }]}>
⚠️ Server offline. Check Settings → Server Connection.
</Text>
</View>
);
}
const styles = StyleSheet.create({
banner: {
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: 1,
},
bannerText: {
fontSize: 13,
textAlign: 'center',
fontWeight: '500',
},
});
-99
View File
@@ -1,99 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
type ThemeColors = ReturnType<typeof useThemeColor>;
/** Style map for the Markdown renderer, derived from the active theme colours. */
export function createMarkdownStyles(colors: ThemeColors) {
return {
body: {
fontSize: 15,
lineHeight: 22,
color: colors.messageAssistantText,
},
strong: {
fontWeight: '700',
color: colors.text,
},
em: {
fontStyle: 'italic',
},
code_inline: {
backgroundColor: colors.backgroundColor,
color: colors.text,
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 4,
fontSize: 13,
fontFamily: 'monospace',
},
fence: {
backgroundColor: colors.inputBg,
borderColor: colors.border,
borderWidth: 1,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
overflow: 'hidden',
},
code_block: {
backgroundColor: colors.inputBg,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: colors.border,
paddingLeft: 12,
marginVertical: 4,
color: colors.textMuted,
},
bullet_list: {
marginVertical: 4,
},
ordered_list: {
marginVertical: 4,
},
list_item: {
marginVertical: 2,
color: colors.messageAssistantText,
},
heading1: {
fontSize: 22,
fontWeight: '700',
marginVertical: 8,
color: colors.text,
},
heading2: {
fontSize: 19,
fontWeight: '700',
marginVertical: 6,
color: colors.text,
},
heading3: {
fontSize: 17,
fontWeight: '600',
marginVertical: 4,
color: colors.text,
},
link: {
color: '#3B82F6',
textDecorationLine: 'underline',
},
hr: {
backgroundColor: colors.border,
height: 1,
marginVertical: 12,
},
paragraph: {
marginVertical: 2,
},
// The Markdown renderer's style types do not accept every RN style key literal.
} as any;
}
+25
View File
@@ -0,0 +1,25 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}
+19
View File
@@ -0,0 +1,19 @@
import Animated from 'react-native-reanimated';
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
'50%': { transform: [{ rotate: '25deg' }] },
},
animationIterationCount: 4,
animationDuration: '300ms',
}}>
👋
</Animated.Text>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/components/themed-view';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useThemeColor } from '@/hooks/use-theme-color';
const HEADER_HEIGHT = 250;
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const backgroundColor = useThemeColor({}, 'background');
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollOffset(scrollRef);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
return (
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16}>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});
-38
View File
@@ -1,38 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { Info, MonitorCog } from 'lucide-react-native';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { SettingsSection } from './SettingsSection';
export function AboutCard() {
const colors = useThemeColor();
return (
<SettingsSection title="ABOUT">
<View style={styles.rowGroup}>
<Info size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.text }]}>LocalMind - Cross Platform Version</Text>
</View>
<View style={[styles.rowGroup, { marginTop: 8 }]}>
<MonitorCog size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.textMuted, fontSize: 13 }]}>
Connected via OpenAPI `/v1/chat/completions` specs
</Text>
</View>
</SettingsSection>
);
}
const styles = StyleSheet.create({
rowGroup: {
flexDirection: 'row',
alignItems: 'center',
},
infoIcon: {
marginRight: 10,
},
infoText: {
fontSize: 15,
lineHeight: 22,
},
});
-62
View File
@@ -1,62 +0,0 @@
import { useColorScheme } from '@/hooks/useColorScheme';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import { isDarkTheme } from '@/utils/theme';
import { Moon } from 'lucide-react-native';
import React from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import { SettingsSection } from './SettingsSection';
export function AppearanceCard() {
const colors = useThemeColor();
const systemScheme = useColorScheme();
const themePreference = useAppStore(state => state.theme);
const setTheme = useAppStore(state => state.setTheme);
const isDarkMode = isDarkTheme(themePreference, systemScheme);
const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark');
return (
<SettingsSection title="APPEARANCE">
<View style={styles.row}>
<View style={styles.rowIconWrap}>
<Moon size={20} color={colors.text} />
</View>
<View style={styles.rowText}>
<Text style={[styles.rowTitle, { color: colors.text }]}>Dark Mode</Text>
<Text style={[styles.rowSubtitle, { color: colors.textMuted }]}>Toggle sleek dark theme</Text>
</View>
<Switch
value={isDarkMode}
onValueChange={toggleTheme}
accessibilityLabel="Dark Mode"
trackColor={{ false: '#E2E8F0', true: colors.tint }}
thumbColor={'#FFFFFF'}
/>
</View>
</SettingsSection>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 4,
},
rowIconWrap: {
marginRight: 12,
},
rowText: {
flex: 1,
},
rowTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
rowSubtitle: {
fontSize: 14,
},
});
-44
View File
@@ -1,44 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react';
import { StyleSheet, Text, TextInput, TextInputProps, View } from 'react-native';
interface LabeledInputProps extends TextInputProps {
label: string;
}
export function LabeledInput({ label, style, ...inputProps }: LabeledInputProps) {
const colors = useThemeColor();
return (
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>{label}</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text },
style,
]}
placeholderTextColor={colors.textMuted}
{...inputProps}
/>
</View>
);
}
const styles = StyleSheet.create({
inputGroup: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
textInput: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
},
});
@@ -1,194 +0,0 @@
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor';
import * as api from '@/services/lmStudioApi';
import { useAppStore } from '@/store/useAppStore';
import { buildServerConfig, isConfigChanged } from '@/utils/serverConfig';
import { Network } from 'lucide-react-native';
import React, { useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { LabeledInput } from './LabeledInput';
import { SettingsSection } from './SettingsSection';
function TestResultBox({ ok }: { ok: boolean }) {
const colors = useThemeColor();
return (
<View style={[styles.resultBox, { backgroundColor: ok ? colors.successBg : colors.errorBg }]}>
<Text style={[styles.resultText, { color: ok ? colors.successText : colors.errorText }]}>
{ok
? 'Connection successful. API is ready.'
: 'Connection failed. Verify host and port.'}
</Text>
</View>
);
}
export function ServerConnectionCard() {
const colors = useThemeColor();
const serverConfig = useAppStore(state => state.serverConfig);
const setServerConfig = useAppStore(state => state.setServerConfig);
const isConnected = useAppStore(state => state.isConnected);
const setIsConnected = useAppStore(state => state.setIsConnected);
const { fetchModels } = useLMStudio();
const [host, setHost] = useState(serverConfig.host);
const [port, setPort] = useState(serverConfig.port.toString());
const [apiKey, setApiKey] = useState(serverConfig.apiKey ?? '');
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<boolean | null>(null);
const form = { host, port, apiKey };
const currentConfig = buildServerConfig(form);
const configChanged = isConfigChanged(form, serverConfig);
const handleSaveConfig = () => {
setServerConfig(currentConfig);
setTestResult(null);
};
const handleTestConnection = async () => {
handleSaveConfig();
setIsTesting(true);
setTestResult(null);
const ok = await api.testConnection(currentConfig);
setTestResult(ok);
setIsConnected(ok);
if (ok) {
await fetchModels();
}
setIsTesting(false);
};
return (
<SettingsSection title="SERVER CONNECTION">
<View style={styles.cardHeader}>
<View style={styles.cardHeaderTitle}>
<Network size={20} color={colors.text} style={styles.headerIcon} />
<Text style={[styles.cardTitle, { color: colors.text }]}>LM Studio API</Text>
</View>
<ConnectionBadge isConnected={isConnected} />
</View>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
<LabeledInput
label="Host IP / Address"
value={host}
onChangeText={setHost}
placeholder="localhost"
autoCapitalize="none"
autoCorrect={false}
/>
<LabeledInput
label="Port Number"
value={port}
onChangeText={setPort}
placeholder="1234"
keyboardType="numeric"
/>
<LabeledInput
label="API Key (optional)"
value={apiKey}
onChangeText={setApiKey}
placeholder="Leave empty for local"
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
/>
<View style={styles.buttonRow}>
{configChanged && (
<TouchableOpacity
style={[styles.saveButton, { borderColor: colors.border, backgroundColor: colors.cardBackground }]}
onPress={handleSaveConfig}
>
<Text style={[styles.saveButtonText, { color: colors.text }]}>Save Changes</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[
styles.testButton,
{ backgroundColor: colors.tint },
isTesting && styles.testButtonDisabled
]}
onPress={handleTestConnection}
disabled={isTesting}
>
{isTesting ? (
<ActivityIndicator size="small" color={colors.backgroundColor} />
) : (
<Text style={[styles.testButtonText, { color: colors.backgroundColor }]}>Test Connection</Text>
)}
</TouchableOpacity>
</View>
{testResult !== null && <TestResultBox ok={testResult} />}
</SettingsSection>
);
}
const styles = StyleSheet.create({
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
},
cardHeaderTitle: {
flexDirection: 'row',
alignItems: 'center',
},
headerIcon: {
marginRight: 8,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
},
divider: {
height: 1,
width: '100%',
marginBottom: 16,
},
buttonRow: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
saveButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
borderWidth: 1,
},
saveButtonText: {
fontSize: 15,
fontWeight: '600',
},
testButton: {
flex: 2,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
testButtonDisabled: {
opacity: 0.7,
},
testButtonText: {
fontSize: 15,
fontWeight: '600',
},
resultBox: {
marginTop: 16,
borderRadius: 12,
padding: 14,
},
resultText: {
fontSize: 14,
textAlign: 'center',
fontWeight: '500',
},
});
-45
View File
@@ -1,45 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react';
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
interface SettingsSectionProps {
title: string;
children: React.ReactNode;
}
export function SettingsSection({ title, children }: SettingsSectionProps) {
const { width } = useWindowDimensions();
const colors = useThemeColor();
const isLargeScreen = width > 768;
return (
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>{title}</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
{children}
</View>
</View>
);
}
const styles = StyleSheet.create({
section: {
marginBottom: 24,
},
desktopSection: {
width: '100%',
maxWidth: 600,
},
sectionLabel: {
fontSize: 13,
fontWeight: '600',
marginLeft: 16,
marginBottom: 8,
letterSpacing: 0.5,
},
card: {
borderRadius: 16,
padding: 16,
borderWidth: 1,
},
});
+60
View File
@@ -0,0 +1,60 @@
import { StyleSheet, Text, type TextProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});
+14
View File
@@ -0,0 +1,14 @@
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}
+118
View File
@@ -0,0 +1,118 @@
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import React from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, TextStyle, ViewStyle } from 'react-native';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
icon?: React.ReactNode;
}
export function Button({
title,
onPress,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
style,
textStyle,
icon
}: ButtonProps) {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'dark'];
const getBackgroundColor = () => {
if (disabled) return theme.surfaceHighlight;
switch (variant) {
case 'primary': return theme.primary;
case 'secondary': return theme.surfaceHighlight;
case 'outline': return 'transparent';
case 'ghost': return 'transparent';
default: return theme.primary;
}
};
const getTextColor = () => {
if (disabled) return theme.textSecondary;
switch (variant) {
case 'primary': return theme.background; // Black text on white bg
case 'secondary': return theme.text;
case 'outline': return theme.text;
case 'ghost': return theme.textSecondary;
default: return theme.background;
}
};
const getBorder = () => {
if (variant === 'outline') return { borderWidth: 1, borderColor: theme.border };
return {};
};
const getPadding = () => {
switch (size) {
case 'sm': return { paddingVertical: 8, paddingHorizontal: 12 };
case 'lg': return { paddingVertical: 16, paddingHorizontal: 24 };
default: return { paddingVertical: 12, paddingHorizontal: 20 };
}
};
const getFontSize = () => {
switch (size) {
case 'sm': return 13;
case 'lg': return 17;
default: return 15;
}
};
return (
<Pressable
onPress={onPress}
disabled={disabled || loading}
style={({ pressed }) => [
styles.button,
{ backgroundColor: getBackgroundColor() },
getBorder(),
getPadding(),
{ opacity: pressed ? 0.8 : 1 },
style
]}
>
{loading ? (
<ActivityIndicator color={getTextColor()} />
) : (
<>
{icon}
<Text style={[
styles.text,
{ color: getTextColor(), fontSize: getFontSize() },
icon ? { marginLeft: 8 } : {},
textStyle
]}>
{title}
</Text>
</>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
text: {
fontWeight: '600',
letterSpacing: 0.5,
},
});
+36
View File
@@ -0,0 +1,36 @@
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { StyleSheet, View, ViewStyle } from 'react-native';
interface GlassCardProps {
children: React.ReactNode;
style?: ViewStyle;
variant?: 'default' | 'highlight';
}
export function GlassCard({ children, style, variant = 'default' }: GlassCardProps) {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'dark'];
return (
<View style={[
styles.card,
{
backgroundColor: variant === 'default' ? theme.surface : theme.surfaceHighlight,
borderColor: theme.border,
},
style
]}>
{children}
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
overflow: 'hidden',
},
});
+66
View File
@@ -0,0 +1,66 @@
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { StatusBar } from 'expo-status-bar';
import { ScrollView, StyleSheet, View, ViewStyle } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface ScreenWrapperProps {
children: React.ReactNode;
style?: ViewStyle;
contentContainerStyle?: ViewStyle;
scrollable?: boolean;
}
export function ScreenWrapper({
children,
style,
contentContainerStyle,
scrollable = false
}: ScreenWrapperProps) {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'dark'];
const insets = useSafeAreaInsets();
const containerStyle = [
styles.container,
{
backgroundColor: theme.background,
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
},
style
];
if (scrollable) {
return (
<View style={{ flex: 1, backgroundColor: theme.background }}>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<ScrollView
style={containerStyle}
contentContainerStyle={[
{ paddingBottom: insets.bottom + 20 },
contentContainerStyle
]}
>
{children}
</ScrollView>
</View>
);
}
return (
<View style={containerStyle}>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<View style={[{ flex: 1, paddingBottom: insets.bottom }, contentContainerStyle]}>
{children}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
+45
View File
@@ -0,0 +1,45 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});
+32
View File
@@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}
+41
View File
@@ -0,0 +1,41 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}
+33
View File
@@ -0,0 +1,33 @@
export type AIModel = {
id: string;
name: string;
parameters: string;
huggingFaceUrl: string;
description: string;
};
export const QWEN_MODELS: AIModel[] = [
{
id: "qwen1.5-0.5b-chat",
name: "Qwen 1.5 Chat",
parameters: "0.5B",
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-0.5B-Chat",
description: "Ultra-lightweight model, extremely fast for basic tasks.",
},
{
id: "qwen1.5-1.8b-chat",
name: "Qwen 1.5 Chat",
parameters: "1.8B",
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-1.8B-Chat",
description: "Good balance of speed and reasoning for mobile devices.",
},
{
id: "qwen1.5-4b-chat",
name: "Qwen 1.5 Chat",
parameters: "4B",
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-4B-Chat",
description: "Highest local quality, requires more RAM and processing power.",
}
];
export const DEFAULT_MODEL = QWEN_MODELS[0];
+87
View File
@@ -0,0 +1,87 @@
/**
* ManusAI Theme - Monochromatic/Minimalist
*
* Design System:
* - Primary: White (#ffffff)
* - Background: Deep Matte Black (#09090b)
* - Surface: Zinc-900 (#18181b)
* - Border: Zinc-700 (#3f3f46)
* - Text: Zinc-50 (#fafafa)
*/
import { Platform } from 'react-native';
const ManusColors = {
// Base
black: '#09090b',
zinc900: '#18181b',
zinc800: '#27272a',
zinc700: '#3f3f46',
zinc400: '#a1a1aa',
zinc50: '#fafafa',
white: '#ffffff',
// Semantic
background: '#09090b',
surface: '#18181b',
surfaceHighlight: '#27272a',
primary: '#ffffff',
border: '#3f3f46',
text: '#fafafa',
textSecondary: '#a1a1aa',
error: '#ef4444',
};
export const Colors = {
light: {
text: ManusColors.black,
background: ManusColors.white,
tint: ManusColors.black,
icon: ManusColors.zinc700,
tabIconDefault: ManusColors.zinc400,
tabIconSelected: ManusColors.black,
// Manus Specific
surface: '#f4f4f5', // Zinc-100
surfaceHighlight: '#e4e4e7', // Zinc-200
border: '#d4d4d8', // Zinc-300
textSecondary: ManusColors.zinc700,
primary: ManusColors.black,
},
dark: {
text: ManusColors.text,
background: ManusColors.background,
tint: ManusColors.primary,
icon: ManusColors.zinc400,
tabIconDefault: ManusColors.zinc400,
tabIconSelected: ManusColors.primary,
// Manus Specific
surface: ManusColors.surface,
surfaceHighlight: ManusColors.surfaceHighlight,
border: ManusColors.border,
textSecondary: ManusColors.textSecondary,
primary: ManusColors.primary,
},
// Unified access for components that ignore system theme
manus: ManusColors,
};
export const Fonts = Platform.select({
ios: {
sans: 'system-ui',
serif: 'ui-serif',
rounded: 'ui-rounded',
mono: 'ui-monospace',
},
android: {
sans: 'sans-serif',
serif: 'serif',
rounded: 'sans-serif-medium',
mono: 'monospace',
},
default: {
sans: 'system-ui',
serif: 'serif',
rounded: 'system-ui',
mono: 'monospace',
},
});
+1
View File
@@ -0,0 +1 @@
export { useColorScheme } from 'react-native';
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
-23
View File
@@ -1,23 +0,0 @@
import { useEffect } from 'react';
import { useAppStore } from '../store/useAppStore';
import { useLMStudio } from './useLMStudio';
/**
* Checks the server once when the app opens, after the saved settings have been
* restored, so the connection badge and model list are not stale until the user
* visits Settings.
*/
export function useAutoConnect() {
const { fetchModels } = useLMStudio();
useEffect(() => {
const { persist } = useAppStore;
if (persist.hasHydrated()) {
void fetchModels();
return;
}
return persist.onFinishHydration(() => void fetchModels());
// Mount only: later checks are triggered by the user (model selector, Settings).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
+97 -55
View File
@@ -2,16 +2,7 @@ import { useCallback, useRef } from 'react';
import { SYSTEM_PROMPT } from '../constants/SystemPrompt'; import { SYSTEM_PROMPT } from '../constants/SystemPrompt';
import * as api from '../services/lmStudioApi'; import * as api from '../services/lmStudioApi';
import { ChatMessage as ApiMessage } from '../services/types'; import { ChatMessage as ApiMessage } from '../services/types';
import { ChatMessage, useAppStore } from '../store/useAppStore'; import { useAppStore } from '../store/useAppStore';
const NO_MODEL_MESSAGE = '⚠️ No model selected. Please select a model first.';
function buildApiMessages(messages: ChatMessage[]): ApiMessage[] {
return [
{ role: 'system', content: SYSTEM_PROMPT },
...messages.map((m) => ({ role: m.role, content: m.content })),
];
}
export function useLMStudio() { export function useLMStudio() {
const serverConfig = useAppStore((s) => s.serverConfig); const serverConfig = useAppStore((s) => s.serverConfig);
@@ -53,16 +44,95 @@ export function useLMStudio() {
} }
}, [activeModelId, setAvailableModels, setIsConnected, setActiveModelId]); }, [activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
// Adds an empty assistant message and streams the model's answer into it. const sendMessage = useCallback(async (text: string) => {
const streamAnswer = useCallback((modelId: string, apiMessages: ApiMessage[]) => { if (isStreaming) return;
addMessage({ role: 'user', content: text });
const currentMessages = useAppStore.getState().messages;
const apiMessages: ApiMessage[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...currentMessages.map((m) => ({
role: m.role,
content: m.content,
}))
];
const modelId = useAppStore.getState().activeModelId;
if (!modelId) {
addMessage({
role: 'assistant',
content: '⚠️ No model selected. Please select a model first.',
});
return;
}
const assistantId = addMessage({ role: 'assistant', content: '' }); const assistantId = addMessage({ role: 'assistant', content: '' });
setIsStreaming(true); setIsStreaming(true);
const finish = () => { try {
abortRef.current = api.chatCompletionStream(
serverConfig,
modelId,
apiMessages,
(chunk) => {
appendToMessage(assistantId, chunk);
},
() => {
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
},
(error) => {
const errorMsg = `\n\n⚠️ Error: ${error.message}`;
appendToMessage(assistantId, errorMsg);
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
},
);
} catch (err) {
const errorMsg = `⚠️ Connection failed: ${(err as Error).message}`;
appendToMessage(assistantId, errorMsg);
setIsStreaming(false); setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat(); useAppStore.getState().saveCurrentChat();
}; }
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
const regenerateResponse = useCallback(async () => {
if (isStreaming) return;
const msgs = useAppStore.getState().messages;
// Find the last user message
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') {
lastUserIdx = i;
break;
}
}
if (lastUserIdx === -1) return;
const lastUserMsg = msgs[lastUserIdx];
// Remove messages after the last user message
const store = useAppStore.getState();
store.deleteMessagesAfter(lastUserMsg.id);
// Build API messages from remaining
const remaining = useAppStore.getState().messages;
const apiMessages: ApiMessage[] = [
{ role: 'system', content: SYSTEM_PROMPT },
...remaining.map((m) => ({
role: m.role,
content: m.content,
}))
];
const modelId = useAppStore.getState().activeModelId;
if (!modelId) return;
const assistantId = addMessage({ role: 'assistant', content: '' });
setIsStreaming(true);
try { try {
abortRef.current = api.chatCompletionStream( abortRef.current = api.chatCompletionStream(
@@ -70,58 +140,30 @@ export function useLMStudio() {
modelId, modelId,
apiMessages, apiMessages,
(chunk) => appendToMessage(assistantId, chunk), (chunk) => appendToMessage(assistantId, chunk),
finish, () => {
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
},
(error) => { (error) => {
appendToMessage(assistantId, ` appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
setIsStreaming(false);
⚠️ Error: ${error.message}`); abortRef.current = null;
finish(); useAppStore.getState().saveCurrentChat();
}, },
); );
} catch (err) { } catch (err) {
appendToMessage(assistantId, `⚠️ Connection failed: ${(err as Error).message}`); appendToMessage(assistantId, `⚠️ Connection failed: ${(err as Error).message}`);
finish(); setIsStreaming(false);
useAppStore.getState().saveCurrentChat();
} }
}, [serverConfig, addMessage, appendToMessage, setIsStreaming]); }, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
const sendMessage = useCallback(async (text: string) => {
if (isStreaming) return;
addMessage({ role: 'user', content: text });
const modelId = useAppStore.getState().activeModelId;
if (!modelId) {
addMessage({ role: 'assistant', content: NO_MODEL_MESSAGE });
return;
}
streamAnswer(modelId, buildApiMessages(useAppStore.getState().messages));
}, [isStreaming, addMessage, streamAnswer]);
const regenerateResponse = useCallback(async () => {
if (isStreaming) return;
const { messages: msgs, activeModelId: modelId, deleteMessagesAfter } = useAppStore.getState();
const lastUserMsg = [...msgs].reverse().find((m) => m.role === 'user');
if (!lastUserMsg) return;
// Checked before deleting anything, so the current answer is not lost for nothing.
if (!modelId) {
addMessage({ role: 'assistant', content: NO_MODEL_MESSAGE });
return;
}
// Drop the previous answer(s) and ask again from the last user message.
deleteMessagesAfter(lastUserMsg.id);
streamAnswer(modelId, buildApiMessages(useAppStore.getState().messages));
}, [isStreaming, addMessage, streamAnswer]);
const stopStreaming = useCallback(() => { const stopStreaming = useCallback(() => {
if (abortRef.current) { if (abortRef.current) {
abortRef.current.abort(); abortRef.current.abort();
abortRef.current = null; abortRef.current = null;
setIsStreaming(false); setIsStreaming(false);
useAppStore.getState().saveCurrentChat();
} }
}, [setIsStreaming]); }, [setIsStreaming]);
-13
View File
@@ -1,13 +0,0 @@
module.exports = {
preset: 'jest-expo',
setupFiles: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
// jest-expo's default pattern plus the ESM-only markdown renderer.
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@ronradtke/.*)',
],
testMatch: ['<rootDir>/__tests__/**/*.test.{ts,tsx}'],
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/.agent/'],
};
-3
View File
@@ -1,3 +0,0 @@
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);
+813 -3363
View File
File diff suppressed because it is too large. Load diff
+14 -17
View File
@@ -4,15 +4,14 @@
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android", "android": "expo start --android",
"ios": "expo start --ios", "ios": "expo start --ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint", "lint": "expo lint"
"typecheck": "tsc --noEmit",
"test": "jest",
"build": "expo export --platform web"
}, },
"dependencies": { "dependencies": {
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/runtime": "^7.28.6", "@babel/runtime": "^7.28.6",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -20,25 +19,29 @@
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"@ronradtke/react-native-markdown-display": "^8.1.0", "@ronradtke/react-native-markdown-display": "^8.1.0",
"expo": "~54.0.37", "expo": "~54.0.31",
"expo-clipboard": "~8.0.8", "expo-clipboard": "^55.0.8",
"expo-constants": "~18.0.14", "expo-constants": "~18.0.13",
"expo-font": "~14.0.12", "expo-font": "~14.0.10",
"expo-haptics": "~15.0.8", "expo-haptics": "~15.0.8",
"expo-linking": "~8.0.12", "expo-image": "~3.0.11",
"expo-router": "~6.0.24", "expo-linking": "~8.0.11",
"expo-router": "~6.0.21",
"expo-splash-screen": "~31.0.13", "expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9", "expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"immer": "^11.1.4", "immer": "^11.1.4",
"lucide-react-native": "^0.576.0", "lucide-react-native": "^0.576.0",
"react": "19.1.0", "react": "^19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-sse": "^1.2.1",
"react-native-svg": "15.12.1", "react-native-svg": "15.12.1",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1", "react-native-worklets": "0.5.1",
@@ -46,15 +49,9 @@
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^29.5.14",
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"babel-preset-expo": "~54.0.12",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"jest": "^29.7.0",
"jest-expo": "~54.0.0",
"react-test-renderer": "^19.1.0",
"typescript": "~5.9.2" "typescript": "~5.9.2"
}, },
"private": true "private": true
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
const exampleDir = "app-example";
const newAppDir = "app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);
+21 -39
View File
@@ -9,18 +9,12 @@ import {
StreamChunk, StreamChunk,
} from './types'; } from './types';
export function buildBaseUrl(config: ServerConfig): string { function buildBaseUrl(config: ServerConfig): string {
const host = config.host.replace(/\/+$/, ''); const host = config.host.replace(/\/+$/, '');
const protocol = /^https?:\/\//i.test(host) ? '' : 'http://'; const protocol = host.startsWith('http') ? '' : 'http://';
return `${protocol}${host}:${config.port}/v1`; return `${protocol}${host}:${config.port}/v1`;
} }
function connectionError(baseUrl: string): Error {
return new Error(
`Could not reach the server at ${baseUrl}. Check the host and port in Settings and that LM Studio's local server is running.`,
);
}
function buildHeaders(config: ServerConfig): Record<string, string> { function buildHeaders(config: ServerConfig): Record<string, string> {
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -32,19 +26,19 @@ function buildHeaders(config: ServerConfig): Record<string, string> {
} }
export async function testConnection(config: ServerConfig): Promise<boolean> { export async function testConnection(config: ServerConfig): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try { try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(`${buildBaseUrl(config)}/models`, { const res = await fetch(`${buildBaseUrl(config)}/models`, {
headers: buildHeaders(config), headers: buildHeaders(config),
signal: controller.signal, signal: controller.signal,
}); });
clearTimeout(timeout);
return res.ok; return res.ok;
} catch { } catch {
return false; return false;
} finally {
clearTimeout(timeout);
} }
} }
@@ -90,7 +84,7 @@ export async function chatCompletion(
} }
// Parse SSE lines from a text buffer // Parse SSE lines from a text buffer
export function parseSSELines( function parseSSELines(
buffer: string, buffer: string,
onChunk: (content: string) => void, onChunk: (content: string) => void,
onDone: () => void, onDone: () => void,
@@ -101,9 +95,9 @@ export function parseSSELines(
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue; if (!trimmed || !trimmed.startsWith('data: ')) continue;
const payload = trimmed.slice(5).trim(); const payload = trimmed.slice(6);
if (payload === '[DONE]') { if (payload === '[DONE]') {
isDone = true; isDone = true;
onDone(); onDone();
@@ -129,7 +123,6 @@ function streamWithXHR(
url: string, url: string,
headers: Record<string, string>, headers: Record<string, string>,
body: string, body: string,
baseUrl: string,
onChunk: (content: string) => void, onChunk: (content: string) => void,
onDone: () => void, onDone: () => void,
onError: (error: Error) => void, onError: (error: Error) => void,
@@ -153,10 +146,6 @@ function streamWithXHR(
}; };
xhr.onload = () => { xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
onError(new Error(`Stream failed: ${xhr.status} ${xhr.statusText}`));
return;
}
// Process any remaining buffer // Process any remaining buffer
if (buffer.trim()) { if (buffer.trim()) {
parseSSELines(buffer + '\n', onChunk, onDone); parseSSELines(buffer + '\n', onChunk, onDone);
@@ -165,7 +154,7 @@ function streamWithXHR(
}; };
xhr.onerror = () => { xhr.onerror = () => {
onError(connectionError(baseUrl)); onError(new Error(`XHR streaming failed: ${xhr.status} ${xhr.statusText}`));
}; };
xhr.ontimeout = () => { xhr.ontimeout = () => {
@@ -186,23 +175,17 @@ async function streamWithFetch(
url: string, url: string,
headers: Record<string, string>, headers: Record<string, string>,
body: string, body: string,
baseUrl: string,
onChunk: (content: string) => void, onChunk: (content: string) => void,
onDone: () => void, onDone: () => void,
onError: (error: Error) => void,
signal: AbortSignal, signal: AbortSignal,
): Promise<void> { ): Promise<void> {
let res: Response; const res = await fetch(url, {
try { method: 'POST',
res = await fetch(url, { headers,
method: 'POST', body,
headers, signal,
body, });
signal,
});
} catch (err) {
if ((err as Error).name === 'AbortError') throw err;
throw connectionError(baseUrl);
}
if (!res.ok) { if (!res.ok) {
throw new Error(`Stream failed: ${res.status} ${res.statusText}`); throw new Error(`Stream failed: ${res.status} ${res.statusText}`);
@@ -246,8 +229,7 @@ export function chatCompletionStream(
...options, ...options,
}; };
const baseUrl = buildBaseUrl(config); const url = `${buildBaseUrl(config)}/chat/completions`;
const url = `${baseUrl}/chat/completions`;
const headers = buildHeaders(config); const headers = buildHeaders(config);
const bodyStr = JSON.stringify(body); const bodyStr = JSON.stringify(body);
@@ -263,7 +245,7 @@ export function chatCompletionStream(
// Web: use fetch + ReadableStream // Web: use fetch + ReadableStream
const controller = new AbortController(); const controller = new AbortController();
streamWithFetch(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, controller.signal) streamWithFetch(url, headers, bodyStr, onChunk, safeOnDone, onError, controller.signal)
.catch((err) => { .catch((err) => {
if ((err as Error).name !== 'AbortError') { if ((err as Error).name !== 'AbortError') {
onError(err as Error); onError(err as Error);
@@ -273,6 +255,6 @@ export function chatCompletionStream(
return { abort: () => controller.abort() }; return { abort: () => controller.abort() };
} else { } else {
// Native: use XMLHttpRequest // Native: use XMLHttpRequest
return streamWithXHR(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, onError); return streamWithXHR(url, headers, bodyStr, onChunk, safeOnDone, onError);
} }
} }
-54
View File
@@ -1,54 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppState } from 'react-native';
import type { StateStorage } from 'zustand/middleware';
export interface DebouncedStorage extends StateStorage {
/** Writes every pending value right away. */
flush: () => Promise<void>;
}
/**
* Wraps a storage so bursts of writes collapse into one trailing write.
*
* The chat store is persisted on every state change and a streamed answer
* changes it once per token, which would serialize every saved session and hit
* AsyncStorage dozens of times per second.
*/
export function createDebouncedStorage(base: StateStorage, delayMs = 400): DebouncedStorage {
const pending = new Map<string, string>();
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = async () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
const entries = [...pending.entries()];
pending.clear();
await Promise.all(entries.map(([name, value]) => base.setItem(name, value)));
};
return {
getItem: (name) => {
const value = pending.get(name);
return value === undefined ? base.getItem(name) : Promise.resolve(value);
},
setItem: (name, value) => {
pending.set(name, value);
if (timer) clearTimeout(timer);
timer = setTimeout(flush, delayMs);
},
removeItem: (name) => {
pending.delete(name);
return base.removeItem(name);
},
flush,
};
}
export const appStorage = createDebouncedStorage(AsyncStorage);
// Do not lose the last tokens when the app is sent to the background or closed.
AppState.addEventListener('change', (state) => {
if (state !== 'active') void appStorage.flush();
});
+2 -3
View File
@@ -1,7 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { create } from 'zustand'; import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware'; import { createJSONStorage, persist } from 'zustand/middleware';
import { DEFAULT_SERVER_CONFIG, LMModel, ServerConfig } from '../services/types'; import { DEFAULT_SERVER_CONFIG, LMModel, ServerConfig } from '../services/types';
import { appStorage } from './debouncedStorage';
export interface ChatMessage { export interface ChatMessage {
id: string; id: string;
@@ -164,11 +164,10 @@ export const useAppStore = create<AppState>()(
}), }),
{ {
name: 'localaiapp-storage', name: 'localaiapp-storage',
storage: createJSONStorage(() => appStorage), storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({ partialize: (state) => ({
theme: state.theme, theme: state.theme,
serverConfig: state.serverConfig, serverConfig: state.serverConfig,
activeModelId: state.activeModelId,
chatSessions: state.chatSessions, chatSessions: state.chatSessions,
messages: state.messages, messages: state.messages,
currentChatId: state.currentChatId, currentChatId: state.currentChatId,
-16
View File
@@ -1,16 +0,0 @@
import type { ChatMessage } from '../store/useAppStore';
/** Id of the most recent assistant message, or null when there is none. */
export function findLastAssistantId(messages: ChatMessage[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') return messages[i].id;
}
return null;
}
/** True while the model has been asked to answer but has not produced any text yet. */
export function shouldShowTypingIndicator(messages: ChatMessage[], isStreaming: boolean): boolean {
if (!isStreaming) return false;
const last = messages[messages.length - 1];
return last?.role === 'assistant' && last.content === '';
}
-25
View File
@@ -1,25 +0,0 @@
import { DEFAULT_SERVER_CONFIG, ServerConfig } from '../services/types';
export interface ServerConfigForm {
host: string;
port: string;
apiKey: string;
}
/** Turns the raw text fields of the settings form into a usable ServerConfig. */
export function buildServerConfig(form: ServerConfigForm): ServerConfig {
return {
host: form.host.trim() || DEFAULT_SERVER_CONFIG.host,
port: parseInt(form.port, 10) || DEFAULT_SERVER_CONFIG.port,
apiKey: form.apiKey.trim() || undefined,
};
}
/** True when the form differs from what is currently stored. */
export function isConfigChanged(form: ServerConfigForm, saved: ServerConfig): boolean {
return (
form.host !== saved.host ||
form.port !== saved.port.toString() ||
form.apiKey !== (saved.apiKey ?? '')
);
}
-7
View File
@@ -1,7 +0,0 @@
/** Whether the dark theme is in effect for a stored preference and the OS scheme. */
export function isDarkTheme(
preference: 'light' | 'dark' | 'system',
systemScheme: 'light' | 'dark',
): boolean {
return preference === 'dark' || (preference === 'system' && systemScheme === 'dark');
}