Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8d7d61e9a | ||
|
|
541aefd7e4 | ||
|
|
1a3ce9ccec | ||
|
|
b441339bb4 | ||
|
|
0e5b38fd15 | ||
|
|
200fb6afd7 | ||
|
|
7ee78af76d | ||
|
|
2a294f1f87 | ||
|
|
434cba2a69 | ||
|
|
cb6eda0562 | ||
|
|
808cb1610b | ||
|
|
37dfa265f5 |
No files matched your search
@@ -0,0 +1,21 @@
|
|||||||
|
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
|
||||||
@@ -1,83 +1,190 @@
|
|||||||
<div align="center">
|
# LocalMind
|
||||||
|
|
||||||
<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">
|
[](https://github.com/evandrodevbr/LocalMind/actions/workflows/ci.yml)
|
||||||
|
|
||||||

|
**Chat client for local LLMs served by LM Studio. React Native + Expo app (Android, iOS and web) where every message stays on your own network: no cloud, no API key, no telemetry.**
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
[Quick start](#quick-start) · [Issues](https://git.evandro.dev.br/evandro/LocalMind/issues)
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
</div>
|
## About
|
||||||
|
|
||||||
## What it is
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## 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.
|
```
|
||||||
- `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`.
|
Expo app (Android / iOS / web)
|
||||||
- `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.
|
app/chat, app/settings ......... screens (expo-router, file based)
|
||||||
|
hooks/useLMStudio .............. sendMessage / regenerate / stop, stream callbacks
|
||||||
|
services/lmStudioApi ........... HTTP + SSE client
|
||||||
|
store/useAppStore .............. Zustand + AsyncStorage (sessions, settings, theme)
|
||||||
|
|
|
||||||
|
| GET /v1/models
|
||||||
|
| POST /v1/chat/completions (stream: true -> SSE)
|
||||||
|
v
|
||||||
|
LM Studio local server (default http://localhost:1234)
|
||||||
|
```
|
||||||
|
|
||||||
|
- The connection settings (host, port, optional API key) live in the Zustand store and are persisted with `AsyncStorage`.
|
||||||
|
- `services/lmStudioApi.ts` builds the base URL as `http(s)://<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://git.evandro.dev.br/evandro/LocalMind.git
|
git clone https://github.com/evandrodevbr/LocalMind.git
|
||||||
cd LocalMind
|
cd LocalMind
|
||||||
npm install
|
npm install
|
||||||
npm run start # Expo CLI: press w (web), a (Android), i (iOS) or scan the QR code with Expo Go
|
npx expo start
|
||||||
```
|
```
|
||||||
|
|
||||||
`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/`.
|
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.
|
||||||
|
|
||||||
**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.
|
Start the model side first:
|
||||||
|
|
||||||
## Configuration
|
1. Open LM Studio, download a model and go to the **Local Server** tab (the `<->` icon).
|
||||||
|
2. Enable **CORS** and keep the port at `1234` (or note whatever you change it to).
|
||||||
|
3. Click **Start Server**.
|
||||||
|
|
||||||
The app reads no environment variables. The server address is a runtime setting stored on the device:
|
Finally, in the app:
|
||||||
|
|
||||||
| Setting | Default | Notes |
|
1. Open **Settings** (gear icon in the chat header).
|
||||||
|
2. Fill **Host IP / Address** and **Port Number**. On a physical device this must be your machine's LAN address (`192.168.x.x`), not `localhost`.
|
||||||
|
3. Tap **Test Connection**. The badge turns to *Connected* and the model list becomes available in the chat header selector.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
| Screen | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Chat (`/chat`) | Conversation list with Markdown rendering, streaming answers, copy code blocks, edit / delete / regenerate a message, stop generation while it streams, model selector in the header, "new chat" button. |
|
||||||
|
| History (`/chat/history`) | Saved sessions, most recent first, with message count and date; tap to reload, trash icon to delete. |
|
||||||
|
| Settings (`/settings`) | Host, port and optional API key, connection test and status badge, light/dark toggle, app info. |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
The app is a client of the LM Studio OpenAI-compatible API. Endpoints actually used:
|
||||||
|
|
||||||
|
| Call | Where | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Host IP / Address | `localhost` | `http://` is prepended when the value does not start with `http` |
|
| `GET /v1/models` | `testConnection()`, `getModels()` | The connection test aborts after 5 s; the model list feeds the header selector. |
|
||||||
| Port Number | `1234` | LM Studio's default port |
|
| `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. |
|
||||||
| API Key (optional) | empty | Sent as `Authorization: Bearer <key>` only when filled in |
|
|
||||||
|
|
||||||
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`.
|
- Base URL: `http://<host>:<port>/v1` (`http://` is prepended when the host has no `http://` or `https://` scheme).
|
||||||
|
- 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": ... } ] }`.
|
||||||
|
|
||||||
## Project status
|
## Web target
|
||||||
|
|
||||||
Alpha, with no tests and no CI. Known gaps:
|
The same codebase exports a static web build:
|
||||||
|
|
||||||
- The selected model and the model list are not persisted, so you pick a model again after a restart.
|
```bash
|
||||||
- `constants/Models.ts` (a Qwen model catalog) and the `react-native-sse` dependency are not used by any code.
|
npx expo export --platform web # writes dist/
|
||||||
- `app.json` and the storage key still carry the previous project name (`LocalAiApp`, `localaiapp-storage`).
|
npx serve dist # or any static server that maps /chat -> chat/index.html
|
||||||
- Both `package-lock.json` and `pnpm-lock.yaml` are committed.
|
```
|
||||||
|
|
||||||
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/).
|
The export generates `/`, `/chat`, `/chat/history`, `/settings`, `/_sitemap` and `/+not-found`. Use a static server that resolves extension-less URLs (`npx serve`, nginx `try_files $uri $uri.html $uri/ =404`); point unknown paths at `+not-found.html` for the app's 404 screen.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── _layout.tsx root stack, theme provider, splash screen
|
||||||
|
├── index.tsx redirects to /chat
|
||||||
|
├── +not-found.tsx 404 screen
|
||||||
|
├── chat/
|
||||||
|
│ ├── _layout.tsx chat header (model selector, history, new chat, settings), auto-connect
|
||||||
|
│ ├── index.tsx conversation screen, edit/regenerate flow
|
||||||
|
│ └── history.tsx saved sessions
|
||||||
|
└── settings/index.tsx composes the settings cards
|
||||||
|
components/
|
||||||
|
├── chat/ ChatInput, MessageList, MessageBubble, MessageContent, CodeBlock,
|
||||||
|
│ MessageActions, ModelSelector, EmptyState, OfflineBanner, TypingIndicator
|
||||||
|
├── settings/ ServerConnectionCard, AppearanceCard, AboutCard, LabeledInput, SettingsSection
|
||||||
|
└── ui/ ConnectionBadge
|
||||||
|
constants/ Colors, SystemPrompt (system prompt sent to the model)
|
||||||
|
hooks/ useLMStudio (chat flow), useAutoConnect, useThemeColor, useColorScheme
|
||||||
|
services/ lmStudioApi (HTTP/SSE), types
|
||||||
|
store/ useAppStore (Zustand), debouncedStorage (AsyncStorage wrapper)
|
||||||
|
utils/ pure helpers (server form parsing, theme, chat selectors)
|
||||||
|
__tests__/ Jest tests
|
||||||
|
docs/ LM Studio API notes and design reference
|
||||||
|
.agent/ AI development tooling (agents, skills, rules), not app code
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `npm start` | Expo dev server (`npm run web`, `npm run android`, `npm run ios` pick a target) |
|
||||||
|
| `npm run typecheck` | `tsc --noEmit` |
|
||||||
|
| `npm run lint` | `expo lint` |
|
||||||
|
| `npm test` | Jest (`jest-expo`), tests in `__tests__/` |
|
||||||
|
| `npm run build` | `expo export --platform web`, writes `dist/` |
|
||||||
|
|
||||||
|
The GitHub Actions workflow (`.github/workflows/ci.yml`) runs `typecheck`, `lint`, `test` and `build` on pushes to `main` and on pull requests.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`npm test` runs 11 suites (120 tests) covering the pure and stateful logic and the main screens:
|
||||||
|
|
||||||
|
- `lmStudioApi`: base URL building, SSE parsing, connection test and timeout, model list, web (`fetch`) and native (`XMLHttpRequest`) streaming, HTTP and connection errors;
|
||||||
|
- `useAppStore`, `debouncedStorage`: messages, chat sessions, persistence, write debouncing;
|
||||||
|
- `useLMStudio`, `useAutoConnect`: sending, regenerating, stopping, model loading;
|
||||||
|
- `ChatScreen`, `SettingsScreen`, `MessageBubble`, `MessageList`, `ChatInput` (rendered with Testing Library) and `utils`.
|
||||||
|
|
||||||
|
The native streaming path is exercised against a fake `XMLHttpRequest`, not a real device.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Last run on Windows 11 (Node 26.7.0, npm): `npm ci`, `npm run typecheck`, `npm run lint` (0 errors, 0 warnings), `npm test` (120 passed) and `npm run build` (6 static routes in `dist/`) all succeed.
|
||||||
|
|
||||||
|
Not verified: Android and iOS builds (no emulator or device available) and a run against a real LM Studio server. An earlier manual run of the exported web build against a mock of the `/v1` API (headless Chromium) answered a chat message and persisted the session in `localStorage` under `localaiapp-storage`.
|
||||||
|
|
||||||
|
## Current state and limitations
|
||||||
|
|
||||||
|
- Alpha. It was built against models served by LM Studio and has no fallback to any cloud API.
|
||||||
|
- The server is checked once when the chat opens and whenever the model selector is refreshed or Settings tests the connection; there is no periodic reconnect.
|
||||||
|
- The stored model id is not validated against the server's model list after a restart.
|
||||||
|
- Both `package-lock.json` and `pnpm-lock.yaml` are committed; only `package-lock.json` is kept in sync (CI uses `npm ci`).
|
||||||
|
- `app.json` still carries the previous project name (`LocalAiApp` / slug `localaiapp`) and the persisted storage key is `localaiapp-storage`.
|
||||||
|
- No license file.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Document | Content |
|
||||||
|
|---|---|
|
||||||
|
| [`docs/Documentação de Implementaçoes Futuras.md`](docs/Documenta%C3%A7%C3%A3o%20de%20Implementa%C3%A7oes%20Futuras.md) | Notes on the LM Studio API (`/api/v0`, `/v1`, SDKs, CLI) and roadmap ideas (in Portuguese) |
|
||||||
|
| [`docs/evandro-design-system.html`](docs/evandro-design-system.html) | Visual reference of the design system used in the UI |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
No published license: all rights reserved.
|
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.
|
||||||
|
|
||||||
<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>
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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()'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
@@ -10,6 +11,7 @@ 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 = [
|
||||||
@@ -36,6 +38,8 @@ 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}
|
||||||
>
|
>
|
||||||
@@ -43,6 +47,8 @@ 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}
|
||||||
>
|
>
|
||||||
@@ -53,6 +59,8 @@ 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}
|
||||||
>
|
>
|
||||||
|
|||||||
+10
-1
@@ -20,7 +20,12 @@ 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 onPress={() => router.back()} style={styles.backButton}>
|
<TouchableOpacity
|
||||||
|
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>
|
||||||
@@ -43,6 +48,8 @@ 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}>
|
||||||
@@ -55,6 +62,8 @@ 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>
|
||||||
|
|||||||
+17
-176
@@ -1,20 +1,12 @@
|
|||||||
import { ChatInput } from '@/components/chat/ChatInput';
|
import { ChatInput } from '@/components/chat/ChatInput';
|
||||||
import { MessageBubble } from '@/components/chat/MessageBubble';
|
import { EmptyState } from '@/components/chat/EmptyState';
|
||||||
import { TypingIndicator } from '@/components/chat/TypingIndicator';
|
import { MessageList } from '@/components/chat/MessageList';
|
||||||
|
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 { MessageSquare, Sparkles } from 'lucide-react-native';
|
import React, { useCallback } from 'react';
|
||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import { KeyboardAvoidingView, Platform, StyleSheet } from 'react-native';
|
||||||
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
|
||||||
@@ -25,7 +17,6 @@ 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
|
||||||
@@ -37,122 +28,35 @@ 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.
|
||||||
setTimeout(() => {
|
regenerateResponse();
|
||||||
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, sendMessage]);
|
}, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, regenerateResponse, 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 && (
|
{!isConnected && <OfflineBanner />}
|
||||||
<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 ? (
|
||||||
<View style={[styles.emptyState, { backgroundColor: colors.backgroundColor }]}>
|
<EmptyState onSelectPrompt={sendMessage} />
|
||||||
<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
|
||||||
<FlatList
|
messages={messages}
|
||||||
ref={flatListRef}
|
isStreaming={isStreaming}
|
||||||
data={messages}
|
onEdit={handleEdit}
|
||||||
keyExtractor={(item) => item.id}
|
onDelete={handleDelete}
|
||||||
renderItem={({ item, index }) => (
|
onRegenerate={regenerateResponse}
|
||||||
<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
|
||||||
@@ -170,67 +74,4 @@ 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',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
+8
-338
@@ -1,76 +1,14 @@
|
|||||||
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
|
import { AboutCard } from '@/components/settings/AboutCard';
|
||||||
import { useLMStudio } from '@/hooks/useLMStudio';
|
import { AppearanceCard } from '@/components/settings/AppearanceCard';
|
||||||
|
import { ServerConnectionCard } from '@/components/settings/ServerConnectionCard';
|
||||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||||
import * as api from '@/services/lmStudioApi';
|
import React from 'react';
|
||||||
import { ServerConfig } from '@/services/types';
|
import { ScrollView, StyleSheet, useWindowDimensions } from 'react-native';
|
||||||
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
|
||||||
@@ -80,151 +18,9 @@ export default function SettingsScreen() {
|
|||||||
isLargeScreen && styles.desktopContent,
|
isLargeScreen && styles.desktopContent,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{/* Server Connection Section */}
|
<ServerConnectionCard />
|
||||||
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
|
<AppearanceCard />
|
||||||
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>SERVER CONNECTION</Text>
|
<AboutCard />
|
||||||
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -241,130 +37,4 @@ 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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -65,7 +65,12 @@ 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 onPress={handleCancelEdit} style={styles.editCancelBtn}>
|
<TouchableOpacity
|
||||||
|
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>
|
||||||
@@ -80,6 +85,7 @@ 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}
|
||||||
@@ -94,6 +100,8 @@ 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>
|
||||||
@@ -106,6 +114,9 @@ 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>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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}>
|
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Copy message">
|
||||||
<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}>
|
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Edit message">
|
||||||
<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}>
|
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Regenerate response">
|
||||||
<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}>
|
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Delete message">
|
||||||
<Trash2 size={16} color={colors.textMuted} />
|
<Trash2 size={16} color={colors.textMuted} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||||
import { ChatMessage } from '@/store/useAppStore';
|
import { ChatMessage } from '@/store/useAppStore';
|
||||||
import Markdown from '@ronradtke/react-native-markdown-display';
|
import { Bot, User } from 'lucide-react-native';
|
||||||
import * as Clipboard from 'expo-clipboard';
|
import React, { useState } from 'react';
|
||||||
import { Bot, Check, Copy, User } from 'lucide-react-native';
|
import { StyleSheet, Text, useWindowDimensions, View } from '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;
|
||||||
@@ -16,6 +15,58 @@ 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,
|
||||||
@@ -24,217 +75,21 @@ 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 handleCopy = async (text: string) => {
|
const showingStreaming = !!isStreaming && !!isLastAssistant;
|
||||||
await Clipboard.setStringAsync(text);
|
const actionsVisible = (!isUser || showActions) && message.content.length > 0;
|
||||||
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}
|
||||||
>
|
>
|
||||||
<View style={[
|
<BubbleRow message={message} showingStreaming={showingStreaming} />
|
||||||
styles.container,
|
<BubbleMeta message={message} />
|
||||||
isUser ? styles.containerUser : styles.containerAssistant,
|
|
||||||
]}>
|
|
||||||
{/* Avatar */}
|
|
||||||
{!isUser && (
|
|
||||||
<View style={[styles.avatarAssistant, { backgroundColor: colors.border }]}>
|
|
||||||
<Bot size={16} color={colors.text} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={[
|
{actionsVisible && (
|
||||||
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}
|
||||||
@@ -266,15 +121,7 @@ const styles = StyleSheet.create({
|
|||||||
containerAssistant: {
|
containerAssistant: {
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
},
|
},
|
||||||
avatarAssistant: {
|
avatar: {
|
||||||
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,
|
||||||
@@ -295,10 +142,6 @@ 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,
|
||||||
@@ -316,45 +159,4 @@ 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',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -42,6 +42,8 @@ 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}>
|
||||||
@@ -70,6 +72,8 @@ 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} />
|
||||||
@@ -108,6 +112,9 @@ 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 }]}>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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} />;
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// 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} />;
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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];
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 +0,0 @@
|
|||||||
export { useColorScheme } from 'react-native';
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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';
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
+55
-97
@@ -2,7 +2,16 @@ 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 { useAppStore } from '../store/useAppStore';
|
import { ChatMessage, 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);
|
||||||
@@ -44,95 +53,16 @@ export function useLMStudio() {
|
|||||||
}
|
}
|
||||||
}, [activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
|
}, [activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
|
||||||
|
|
||||||
const sendMessage = useCallback(async (text: string) => {
|
// Adds an empty assistant message and streams the model's answer into it.
|
||||||
if (isStreaming) return;
|
const streamAnswer = useCallback((modelId: string, apiMessages: ApiMessage[]) => {
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
try {
|
const finish = () => {
|
||||||
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(
|
||||||
@@ -140,30 +70,58 @@ 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, `\n\n⚠️ Error: ${error.message}`);
|
appendToMessage(assistantId, `
|
||||||
setIsStreaming(false);
|
|
||||||
abortRef.current = null;
|
⚠️ Error: ${error.message}`);
|
||||||
useAppStore.getState().saveCurrentChat();
|
finish();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
appendToMessage(assistantId, `⚠️ Connection failed: ${(err as Error).message}`);
|
appendToMessage(assistantId, `⚠️ Connection failed: ${(err as Error).message}`);
|
||||||
setIsStreaming(false);
|
finish();
|
||||||
useAppStore.getState().saveCurrentChat();
|
|
||||||
}
|
}
|
||||||
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
|
}, [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]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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/'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
jest.mock('@react-native-async-storage/async-storage', () =>
|
||||||
|
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
|
||||||
|
);
|
||||||
Generated
+3363
-813
File diff suppressed because it is too large.
Load diff
+17
-14
@@ -4,14 +4,15 @@
|
|||||||
"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",
|
||||||
@@ -19,29 +20,25 @@
|
|||||||
"@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.31",
|
"expo": "~54.0.37",
|
||||||
"expo-clipboard": "^55.0.8",
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.14",
|
||||||
"expo-font": "~14.0.10",
|
"expo-font": "~14.0.12",
|
||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
"expo-image": "~3.0.11",
|
"expo-linking": "~8.0.12",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-router": "~6.0.24",
|
||||||
"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",
|
||||||
@@ -49,9 +46,15 @@
|
|||||||
"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
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
#!/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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
+39
-21
@@ -9,12 +9,18 @@ import {
|
|||||||
StreamChunk,
|
StreamChunk,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
function buildBaseUrl(config: ServerConfig): string {
|
export function buildBaseUrl(config: ServerConfig): string {
|
||||||
const host = config.host.replace(/\/+$/, '');
|
const host = config.host.replace(/\/+$/, '');
|
||||||
const protocol = host.startsWith('http') ? '' : 'http://';
|
const protocol = /^https?:\/\//i.test(host) ? '' : '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',
|
||||||
@@ -26,19 +32,19 @@ function buildHeaders(config: ServerConfig): Record<string, string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function testConnection(config: ServerConfig): Promise<boolean> {
|
export async function testConnection(config: ServerConfig): Promise<boolean> {
|
||||||
try {
|
const controller = new AbortController();
|
||||||
const controller = new AbortController();
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
||||||
|
|
||||||
|
try {
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +90,7 @@ export async function chatCompletion(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse SSE lines from a text buffer
|
// Parse SSE lines from a text buffer
|
||||||
function parseSSELines(
|
export function parseSSELines(
|
||||||
buffer: string,
|
buffer: string,
|
||||||
onChunk: (content: string) => void,
|
onChunk: (content: string) => void,
|
||||||
onDone: () => void,
|
onDone: () => void,
|
||||||
@@ -95,9 +101,9 @@ function parseSSELines(
|
|||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
if (!trimmed.startsWith('data:')) continue;
|
||||||
|
|
||||||
const payload = trimmed.slice(6);
|
const payload = trimmed.slice(5).trim();
|
||||||
if (payload === '[DONE]') {
|
if (payload === '[DONE]') {
|
||||||
isDone = true;
|
isDone = true;
|
||||||
onDone();
|
onDone();
|
||||||
@@ -123,6 +129,7 @@ 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,
|
||||||
@@ -146,6 +153,10 @@ 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);
|
||||||
@@ -154,7 +165,7 @@ function streamWithXHR(
|
|||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = () => {
|
xhr.onerror = () => {
|
||||||
onError(new Error(`XHR streaming failed: ${xhr.status} ${xhr.statusText}`));
|
onError(connectionError(baseUrl));
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.ontimeout = () => {
|
xhr.ontimeout = () => {
|
||||||
@@ -175,17 +186,23 @@ 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> {
|
||||||
const res = await fetch(url, {
|
let res: Response;
|
||||||
method: 'POST',
|
try {
|
||||||
headers,
|
res = await fetch(url, {
|
||||||
body,
|
method: 'POST',
|
||||||
signal,
|
headers,
|
||||||
});
|
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}`);
|
||||||
@@ -229,7 +246,8 @@ export function chatCompletionStream(
|
|||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
const url = `${buildBaseUrl(config)}/chat/completions`;
|
const baseUrl = buildBaseUrl(config);
|
||||||
|
const url = `${baseUrl}/chat/completions`;
|
||||||
const headers = buildHeaders(config);
|
const headers = buildHeaders(config);
|
||||||
const bodyStr = JSON.stringify(body);
|
const bodyStr = JSON.stringify(body);
|
||||||
|
|
||||||
@@ -245,7 +263,7 @@ export function chatCompletionStream(
|
|||||||
// Web: use fetch + ReadableStream
|
// Web: use fetch + ReadableStream
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
streamWithFetch(url, headers, bodyStr, onChunk, safeOnDone, onError, controller.signal)
|
streamWithFetch(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, 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);
|
||||||
@@ -255,6 +273,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, onChunk, safeOnDone, onError);
|
return streamWithXHR(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, onError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
@@ -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,10 +164,11 @@ export const useAppStore = create<AppState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'localaiapp-storage',
|
name: 'localaiapp-storage',
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: createJSONStorage(() => appStorage),
|
||||||
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,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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 === '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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 ?? '')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** 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');
|
||||||
|
}
|
||||||
Reference in new issue
Block a user