Compare commits

..
Author SHA1 Message Date
evandro e08189f640 wip: snapshot das alterações não commitadas (2026-10-20)
Origem: D:/Documents/Github/LocalAiApp
Arquivos omitidos (segredos, bancos de dados, logs, >50 MB): 0
2026-10-20 00:48:09 -03:00
78 changed files with 4659 additions and 18889 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
# O diretorio .agent/ contem ferramentas de apoio ao desenvolvimento (agentes,
# skills e rules de IA), nao faz parte do app React Native.
.agent/** linguist-vendored
.agent/** linguist-documentation
-21
View File
@@ -1,21 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm test
- run: npm run build
-4
View File
@@ -36,10 +36,6 @@ yarn-error.*
# typescript
*.tsbuildinfo
# python (scripts de apoio em .agent/)
__pycache__/
*.pyc
app-example
# generated native folders
+21
View File
@@ -0,0 +1,21 @@
node_modules/
.next/
dist/
build/
out/
coverage/
.git/
.pnpm-store/
.cache/
*.log
.env
.env.*
__pycache__/
*.pyc
.venv/
venv/
target/
*.exe
*.dll
*.so
*.dylib
+12
View File
@@ -0,0 +1,12 @@
added 885 packages, changed 29 packages, and audited 915 packages in 22s
166 packages are looking for funding
run `npm fund` for details
4 vulnerabilities (1 moderate, 3 high)
To address all issues, run:
npm audit fix
Run `npm audit` for details.
+45 -156
View File
@@ -1,190 +1,79 @@
# LocalMind
[![CI](https://github.com/evandrodevbr/LocalMind/actions/workflows/ci.yml/badge.svg)](https://github.com/evandrodevbr/LocalMind/actions/workflows/ci.yml)
![React Native](https://img.shields.io/badge/React_Native-20232A?style=for-the-badge&logo=react&logoColor=61DAFB) ![Expo](https://img.shields.io/badge/expo-1C1E24?style=for-the-badge&logo=expo&logoColor=#D04A37) ![Zustand](https://img.shields.io/badge/Zustand-443E38?style=for-the-badge&logo=react&logoColor=white) ![LMStudio](https://img.shields.io/badge/Powered_by-LMStudio-5E5DF0?style=for-the-badge)
**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.**
> A fully local, privacy-first AI companion app built with React Native and Expo. Your data never leaves your device.
![React Native](https://img.shields.io/badge/React_Native-0.81.5-20232A?logo=react&logoColor=61DAFB)
![Expo](https://img.shields.io/badge/Expo_SDK-54-1C1E24?logo=expo&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript&logoColor=white)
![Zustand](https://img.shields.io/badge/Zustand-5-443E38?logo=react&logoColor=white)
![LM Studio](https://img.shields.io/badge/LM_Studio-OpenAI--compatible_API-5E5DF0)
This application connects directly to your own local AI models, completely offline. Currently in alpha, it pairs flawlessly with the [LMStudio](https://lmstudio.ai) local inference API. We developed and tested it against **Qwen3.5 (without thinking)** to guarantee a smooth, zero-latency experience.
## About
*Roadmap: Future updates will introduce direct in-app model downloads and support for external APIs.*
Running a model locally in LM Studio is easy; having a decent chat UI for it on the phone is not. LocalMind is that client: it talks straight to the LM Studio local server (the OpenAI-compatible `/v1` endpoints) over your LAN or `localhost`, streams the answer token by token, keeps chat sessions on the device and renders Markdown with code blocks.
---
It is an alpha project. There is no backend, no account and no server to deploy: the app is only the client, and the model runs on your machine.
## Features
## How it works
- **100% Offline Inference:** Complete privacy. Zero cloud dependency.
- **Plug & Play LMStudio:** Instantly detects and fetches active models from your local server.
- **Persistent Sessions:** Chat histories and settings are saved locally using Zustand and `AsyncStorage`.
- **Developer-Friendly UI:** Real-time markdown rendering with syntax highlighting and 1-click code copying.
- **Minimalist Dark Mode:** High-contrast, distraction-free interface built on NativeWind principles.
```
Expo app (Android / iOS / web)
app/chat, app/settings ......... screens (expo-router, file based)
hooks/useLMStudio .............. sendMessage / regenerate / stop, stream callbacks
services/lmStudioApi ........... HTTP + SSE client
store/useAppStore .............. Zustand + AsyncStorage (sessions, settings, theme)
|
| GET /v1/models
| POST /v1/chat/completions (stream: true -> SSE)
v
LM Studio local server (default http://localhost:1234)
```
---
- The connection settings (host, port, optional API key) live in the Zustand store and are persisted with `AsyncStorage`.
- `services/lmStudioApi.ts` builds the base URL as `http(s)://<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.
## Quick Start
## Stack
To run this application, you will need both the frontend development environment and a local AI engine running.
| 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 |
### 1. Model Server (LMStudio)
## Requirements
1. Extract and open [LMStudio](https://lmstudio.ai/).
2. Download a compatible model (e.g., Llama 3, Qwen2.5, Mistral).
3. Open the **Local Server** tab (`<->`).
4. Enable **CORS** and set the port (default: `1234`).
5. Click **Start Server**.
- 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
> **Note:** If testing on a physical mobile device, locate your machine's local IPv4 address (e.g., `192.168.1.X`).
## Quick start
### 2. Mobile Client (App)
Ensure you have [Node.js](https://nodejs.org/) (v18+) and the [Expo Go](https://expo.dev/go) app installed.
```bash
git clone https://github.com/evandrodevbr/LocalMind.git
cd LocalMind
git clone https://github.com/evandrodevbr/LocalAiApp.git
cd LocalAiApp
npm install
npx expo start
```
Then pick a target from the Expo CLI: `w` for web, `a` for Android, `i` for iOS, or scan the QR code with Expo Go.
Scan the QR code with Expo Go, or type `i`/`a` to launch a simulator.
Start the model side first:
### 3. Connection
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**.
1. Open the app and tap the **Settings** icon.
2. Enter your LMStudio server IP and Port (e.g., `192.168.1.X:1234`).
3. Tap **Test Connection**.
Finally, in the app:
Once connected, your available models will load automatically.
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
## Bug Reports & Contributing
| 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. |
Found an issue or have a suggestion? We welcome community involvement!
## API
👉 **[Report an Issue or Request a Feature](https://github.com/evandrodevbr/LocalAiApp/issues)**
The app is a client of the LM Studio OpenAI-compatible API. Endpoints actually used:
When creating an issue, please include:
| Call | Where | Notes |
|---|---|---|
| `GET /v1/models` | `testConnection()`, `getModels()` | The connection test aborts after 5 s; the model list feeds the header selector. |
| `POST /v1/chat/completions` | `chatCompletion()`, `chatCompletionStream()` | Streaming (`stream: true`) is the path used by the chat; SSE lines with `choices[0].delta.content` are appended to the last message. |
- A clear description of the bug.
- Steps to reproduce.
- Relevant screenshots or environment details.
- 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": ... } ] }`.
---
## Web target
## Acknowledgments
The same codebase exports a static web build:
Thank you for exploring this project! Our mission is to share knowledge and foster an open-source collaborative ecosystem around offline AI solutions. All feedback, testing, and code contributions are highly appreciated.
```bash
npx expo export --platform web # writes dist/
npx serve dist # or any static server that maps /chat -> chat/index.html
```
The export generates `/`, `/chat`, `/chat/history`, `/settings`, `/_sitemap` and `/+not-found`. Use a static server that resolves extension-less URLs (`npx serve`, nginx `try_files $uri $uri.html $uri/ =404`); point unknown paths at `+not-found.html` for the app's 404 screen.
## Project structure
```
app/
├── _layout.tsx root stack, theme provider, splash screen
├── index.tsx redirects to /chat
├── +not-found.tsx 404 screen
├── chat/
│ ├── _layout.tsx chat header (model selector, history, new chat, settings), auto-connect
│ ├── index.tsx conversation screen, edit/regenerate flow
│ └── history.tsx saved sessions
└── settings/index.tsx composes the settings cards
components/
├── chat/ ChatInput, MessageList, MessageBubble, MessageContent, CodeBlock,
│ MessageActions, ModelSelector, EmptyState, OfflineBanner, TypingIndicator
├── settings/ ServerConnectionCard, AppearanceCard, AboutCard, LabeledInput, SettingsSection
└── ui/ ConnectionBadge
constants/ Colors, SystemPrompt (system prompt sent to the model)
hooks/ useLMStudio (chat flow), useAutoConnect, useThemeColor, useColorScheme
services/ lmStudioApi (HTTP/SSE), types
store/ useAppStore (Zustand), debouncedStorage (AsyncStorage wrapper)
utils/ pure helpers (server form parsing, theme, chat selectors)
__tests__/ Jest tests
docs/ LM Studio API notes and design reference
.agent/ AI development tooling (agents, skills, rules), not app code
```
## Scripts
| Command | What it does |
|---|---|
| `npm start` | Expo dev server (`npm run web`, `npm run android`, `npm run ios` pick a target) |
| `npm run typecheck` | `tsc --noEmit` |
| `npm run lint` | `expo lint` |
| `npm test` | Jest (`jest-expo`), tests in `__tests__/` |
| `npm run build` | `expo export --platform web`, writes `dist/` |
The GitHub Actions workflow (`.github/workflows/ci.yml`) runs `typecheck`, `lint`, `test` and `build` on pushes to `main` and on pull requests.
## Tests
`npm test` runs 11 suites (120 tests) covering the pure and stateful logic and the main screens:
- `lmStudioApi`: base URL building, SSE parsing, connection test and timeout, model list, web (`fetch`) and native (`XMLHttpRequest`) streaming, HTTP and connection errors;
- `useAppStore`, `debouncedStorage`: messages, chat sessions, persistence, write debouncing;
- `useLMStudio`, `useAutoConnect`: sending, regenerating, stopping, model loading;
- `ChatScreen`, `SettingsScreen`, `MessageBubble`, `MessageList`, `ChatInput` (rendered with Testing Library) and `utils`.
The native streaming path is exercised against a fake `XMLHttpRequest`, not a real device.
## Verification
Last run on Windows 11 (Node 26.7.0, npm): `npm ci`, `npm run typecheck`, `npm run lint` (0 errors, 0 warnings), `npm test` (120 passed) and `npm run build` (6 static routes in `dist/`) all succeed.
Not verified: Android and iOS builds (no emulator or device available) and a run against a real LM Studio server. An earlier manual run of the exported web build against a mock of the `/v1` API (headless Chromium) answered a chat message and persisted the session in `localStorage` under `localaiapp-storage`.
## Current state and limitations
- Alpha. It was built against models served by LM Studio and has no fallback to any cloud API.
- The server is checked once when the chat opens and whenever the model selector is refreshed or Settings tests the connection; there is no periodic reconnect.
- The stored model id is not validated against the server's model list after a restart.
- Both `package-lock.json` and `pnpm-lock.yaml` are committed; only `package-lock.json` is kept in sync (CI uses `npm ci`).
- `app.json` still carries the previous project name (`LocalAiApp` / slug `localaiapp`) and the persisted storage key is `localaiapp-storage`.
- No license file.
## Documentation
| Document | Content |
|---|---|
| [`docs/Documentação de Implementaçoes Futuras.md`](docs/Documenta%C3%A7%C3%A3o%20de%20Implementa%C3%A7oes%20Futuras.md) | Notes on the LM Studio API (`/api/v0`, `/v1`, SDKs, CLI) and roadmap ideas (in Portuguese) |
| [`docs/evandro-design-system.html`](docs/evandro-design-system.html) | Visual reference of the design system used in the UI |
## License
No license file is present in this repository. Without an explicit license the code is "all rights reserved" by default, so adding one (MIT, for example) is a pending decision of the project owner.
<br>
<br>
-39
View File
@@ -1,39 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';
import { ChatInput } from '../components/chat/ChatInput';
describe('ChatInput', () => {
it('sends trimmed text, clears the field and disables send while empty', () => {
const onSend = jest.fn();
render(<ChatInput onSend={onSend} />);
expect(screen.getByLabelText('Send message').props.accessibilityState.disabled).toBe(true);
fireEvent.changeText(screen.getByLabelText('Message'), ' hello ');
fireEvent.press(screen.getByLabelText('Send message'));
expect(onSend).toHaveBeenCalledWith('hello');
expect(screen.getByLabelText('Message').props.value).toBe('');
});
it('shows a stop button instead of send while loading', () => {
const onStop = jest.fn();
render(<ChatInput onSend={jest.fn()} onStop={onStop} isLoading />);
expect(screen.queryByLabelText('Send message')).toBeNull();
fireEvent.press(screen.getByLabelText('Stop generating'));
expect(onStop).toHaveBeenCalledTimes(1);
});
it('fills the field when editing and lets the user cancel', () => {
const onCancelEdit = jest.fn();
render(<ChatInput onSend={jest.fn()} editingText="old text" onCancelEdit={onCancelEdit} />);
expect(screen.getByText('Editing message')).toBeTruthy();
expect(screen.getByLabelText('Message').props.value).toBe('old text');
fireEvent.press(screen.getByLabelText('Cancel editing'));
expect(onCancelEdit).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('Message').props.value).toBe('');
});
});
-221
View File
@@ -1,221 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';
import ChatScreen from '../app/chat/index';
import * as api from '../services/lmStudioApi';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
jest.mock('react-native-reanimated', () => {
const { View } = require('react-native');
const chain: any = new Proxy(() => chain, { get: () => chain });
return { __esModule: true, default: { View }, FadeInDown: chain };
});
jest.mock('../components/chat/MessageBubble', () => {
const { Text } = require('react-native');
return {
MessageBubble: ({ message, isLastAssistant }: any) => (
<Text testID={`bubble-${message.id}`}>{`${message.role}:${message.content}${isLastAssistant ? ':last' : ''}`}</Text>
),
};
});
jest.mock('../components/chat/TypingIndicator', () => {
const { Text } = require('react-native');
return { TypingIndicator: () => <Text>typing-indicator</Text> };
});
let inputProps: any;
jest.mock('../components/chat/ChatInput', () => ({
ChatInput: (props: any) => {
inputProps = props;
return null;
},
}));
const streamMock = api.chatCompletionStream as jest.Mock;
const state = () => useAppStore.getState();
beforeEach(() => {
jest.useFakeTimers();
streamMock.mockReset();
useAppStore.setState(useAppStore.getInitialState(), true);
useAppStore.setState({ isConnected: true, activeModelId: 'model-a' });
});
afterEach(() => {
jest.useRealTimers();
});
describe('ChatScreen', () => {
it('shows the empty state with prompt suggestions when there are no messages', () => {
render(<ChatScreen />);
expect(screen.getByText('Start a conversation')).toBeTruthy();
expect(screen.getByText('Explain recursion in simple terms')).toBeTruthy();
});
it('sends a suggestion as a user message and starts streaming', () => {
render(<ChatScreen />);
fireEvent.press(screen.getByText('Explain recursion in simple terms'));
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual([
'user:Explain recursion in simple terms',
'assistant:',
]);
expect(streamMock).toHaveBeenCalledTimes(1);
expect(streamMock.mock.calls[0][1]).toBe('model-a');
});
it('hides the empty state and lists the messages once there are some', () => {
state().addMessage({ role: 'user', content: 'hello' });
render(<ChatScreen />);
expect(screen.queryByText('Start a conversation')).toBeNull();
expect(screen.getByText('user:hello')).toBeTruthy();
});
it('shows the offline banner only when the server is not connected', () => {
const { rerender } = render(<ChatScreen />);
expect(screen.queryByText(/Server offline/)).toBeNull();
act(() => {
state().setIsConnected(false);
});
rerender(<ChatScreen />);
expect(screen.getByText(/Server offline/)).toBeTruthy();
});
it('shows the typing indicator only while an empty assistant message is streaming', () => {
state().addMessage({ role: 'user', content: 'q' });
const assistantId = state().addMessage({ role: 'assistant', content: '' });
act(() => {
state().setIsStreaming(true);
});
render(<ChatScreen />);
expect(screen.getByText('typing-indicator')).toBeTruthy();
act(() => {
state().appendToMessage(assistantId, 'partial');
});
expect(screen.queryByText('typing-indicator')).toBeNull();
});
it('does not show the typing indicator when not streaming', () => {
state().addMessage({ role: 'assistant', content: '' });
render(<ChatScreen />);
expect(screen.queryByText('typing-indicator')).toBeNull();
});
it('marks only the last assistant message as regenerable', () => {
state().addMessage({ role: 'user', content: 'q1' });
state().addMessage({ role: 'assistant', content: 'a1' });
state().addMessage({ role: 'user', content: 'q2' });
state().addMessage({ role: 'assistant', content: 'a2' });
render(<ChatScreen />);
expect(screen.getByText('assistant:a1')).toBeTruthy();
expect(screen.getByText('assistant:a2:last')).toBeTruthy();
});
it('sends typed text through the normal path', () => {
render(<ChatScreen />);
act(() => {
inputProps.onSend('typed');
});
expect(state().messages[0]).toMatchObject({ role: 'user', content: 'typed' });
expect(streamMock).toHaveBeenCalledTimes(1);
});
describe('editing a message', () => {
const setup = () => {
state().addMessage({ role: 'user', content: 'old question' });
state().addMessage({ role: 'assistant', content: 'old answer' });
const id = state().messages[0].id;
render(<ChatScreen />);
act(() => {
state().setEditingMessageId(id);
});
return id;
};
it('passes the edited message text to the input and clears edit mode on cancel', () => {
setup();
expect(inputProps.editingText).toBe('old question');
act(() => {
inputProps.onCancelEdit();
});
expect(state().editingMessageId).toBeNull();
});
it('rewrites the message, drops the answers after it and regenerates', () => {
const id = setup();
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
expect(state().editingMessageId).toBeNull();
expect(state().messages[0]).toMatchObject({ id, content: 'new question', isEdited: true });
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual([
'user:new question',
'assistant:',
]);
expect(streamMock).toHaveBeenCalledTimes(1);
const [, modelId, apiMessages] = streamMock.mock.calls[0];
expect(modelId).toBe('model-a');
expect(apiMessages.slice(1)).toEqual([{ role: 'user', content: 'new question' }]);
expect(apiMessages[0].role).toBe('system');
});
it('edits and explains that a model is needed when none is selected', () => {
const id = setup();
act(() => {
useAppStore.setState({ activeModelId: '' });
});
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
expect(state().messages).toHaveLength(2);
expect(state().messages[0]).toMatchObject({ id, content: 'new question' });
expect(state().messages[1].content).toContain('No model selected');
expect(streamMock).not.toHaveBeenCalled();
});
it('lets the user stop an answer generated after an edit', () => {
const abort = jest.fn();
streamMock.mockReturnValue({ abort });
setup();
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
act(() => {
inputProps.onStop();
});
expect(abort).toHaveBeenCalledTimes(1);
expect(state().isStreaming).toBe(false);
});
it('saves the edited conversation when the regenerated answer finishes', () => {
streamMock.mockImplementation((_c, _m, _msgs, onChunk, onDone) => {
onChunk('fresh answer');
onDone();
return { abort: jest.fn() };
});
setup();
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
expect(state().chatSessions).toHaveLength(1);
expect(state().chatSessions[0].messages.map((m) => m.content)).toEqual(['new question', 'fresh answer']);
});
});
});
-154
View File
@@ -1,154 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import * as Clipboard from 'expo-clipboard';
import React from 'react';
import { MessageBubble } from '../components/chat/MessageBubble';
import { ChatMessage } from '../store/useAppStore';
jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn(() => Promise.resolve(true)) }));
let actionsProps: any;
jest.mock('../components/chat/MessageActions', () => ({
MessageActions: (props: any) => {
actionsProps = props;
const { Text } = require('react-native');
return <Text>actions</Text>;
},
}));
const message = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
id: '1',
role: 'assistant',
content: 'hello **world**',
timestamp: new Date(2026, 0, 1, 13, 5).getTime(),
...overrides,
});
beforeEach(() => {
actionsProps = undefined;
(Clipboard.setStringAsync as jest.Mock).mockClear();
});
describe('MessageBubble user message', () => {
it('shows the plain text and keeps the actions hidden until touched', () => {
render(<MessageBubble message={message({ role: 'user', content: 'hi there' })} />);
expect(screen.getByText('hi there')).toBeTruthy();
expect(screen.queryByText('actions')).toBeNull();
});
it('reveals edit and delete (but not regenerate) after a touch', () => {
const onEdit = jest.fn();
const onDelete = jest.fn();
const onRegenerate = jest.fn();
render(
<MessageBubble
message={message({ role: 'user', content: 'hi' })}
onEdit={onEdit}
onDelete={onDelete}
onRegenerate={onRegenerate}
isLastAssistant
/>,
);
fireEvent(screen.getByText('hi').parent!.parent!.parent!.parent!, 'touchStart');
expect(screen.getByText('actions')).toBeTruthy();
expect(actionsProps.role).toBe('user');
expect(actionsProps.onEdit).toBe(onEdit);
expect(actionsProps.onDelete).toBe(onDelete);
expect(actionsProps.onRegenerate).toBeUndefined();
});
it('never renders actions for an empty message', () => {
render(<MessageBubble message={message({ role: 'user', content: '' })} />);
fireEvent(screen.getByText(/\d/).parent!.parent!.parent!, 'touchStart');
expect(screen.queryByText('actions')).toBeNull();
});
});
describe('MessageBubble assistant message', () => {
it('renders markdown and always shows actions', () => {
render(<MessageBubble message={message()} />);
expect(screen.getByText('world')).toBeTruthy();
expect(screen.getByText('actions')).toBeTruthy();
});
it('offers regenerate only on the last assistant message and never edit', () => {
const onEdit = jest.fn();
const onRegenerate = jest.fn();
const { rerender } = render(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} />);
expect(actionsProps.onRegenerate).toBeUndefined();
expect(actionsProps.onEdit).toBeUndefined();
rerender(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} isLastAssistant />);
expect(actionsProps.onRegenerate).toBe(onRegenerate);
expect(actionsProps.onEdit).toBeUndefined();
});
it('flags actions as streaming only for the last assistant message', () => {
const { rerender } = render(<MessageBubble message={message()} isStreaming />);
expect(actionsProps.isStreaming).toBeFalsy();
rerender(<MessageBubble message={message()} isStreaming isLastAssistant />);
expect(actionsProps.isStreaming).toBe(true);
});
it('renders nothing inside the bubble or actions while an empty answer is streaming', () => {
render(<MessageBubble message={message({ content: '' })} isStreaming isLastAssistant />);
expect(screen.queryByText('actions')).toBeNull();
});
it('renders an empty answer as markdown when not streaming', () => {
render(<MessageBubble message={message({ content: '' })} />);
expect(screen.queryByText('actions')).toBeNull();
});
});
describe('MessageBubble metadata', () => {
it('shows the time and the edited badge only for edited messages', () => {
const { rerender } = render(<MessageBubble message={message({ role: 'user' })} />);
expect(screen.queryByText(/edited/)).toBeNull();
rerender(<MessageBubble message={message({ role: 'user', isEdited: true })} />);
expect(screen.getByText(/· edited/)).toBeTruthy();
});
});
describe('MessageBubble code blocks', () => {
const fenced = 'text\n\n```ts\nconst a = 1;\n```\n';
it('shows the language, the code and copies it to the clipboard', async () => {
jest.useFakeTimers();
try {
render(<MessageBubble message={message({ content: fenced })} />);
expect(screen.getByText('ts')).toBeTruthy();
expect(screen.getByText(/const a = 1;/)).toBeTruthy();
expect(screen.getByText('Copy')).toBeTruthy();
await act(async () => {
fireEvent.press(screen.getByText('Copy'));
});
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('const a = 1;\n');
expect(screen.getByText('Copied!')).toBeTruthy();
act(() => {
jest.advanceTimersByTime(2000);
});
expect(screen.getByText('Copy')).toBeTruthy();
} finally {
jest.useRealTimers();
}
});
it('labels a fence without a language as "code"', () => {
render(<MessageBubble message={message({ content: '```\nplain\n```\n' })} />);
expect(screen.getByText('code')).toBeTruthy();
});
it('renders indented code blocks with a "code" header and copy button', async () => {
render(<MessageBubble message={message({ content: 'intro\n\n indented()\n' })} />);
expect(screen.getByText('code')).toBeTruthy();
await act(async () => {
fireEvent.press(screen.getByText('Copy'));
});
expect(Clipboard.setStringAsync).toHaveBeenCalledWith(expect.stringContaining('indented()'));
});
});
-78
View File
@@ -1,78 +0,0 @@
import { render, screen } from '@testing-library/react-native';
import React from 'react';
import { MessageList } from '../components/chat/MessageList';
import { ChatMessage } from '../store/useAppStore';
jest.mock('react-native-reanimated', () => {
const { View } = require('react-native');
const chain: any = new Proxy(() => chain, { get: () => chain });
return { __esModule: true, default: { View }, FadeInDown: chain };
});
const mockRenders: Record<string, number> = {};
jest.mock('../components/chat/MessageBubble', () => {
const { Text } = require('react-native');
return {
MessageBubble: ({ message }: any) => {
mockRenders[message.id] = (mockRenders[message.id] ?? 0) + 1;
return <Text>{`${message.id}:${message.content}`}</Text>;
},
};
});
jest.mock('../components/chat/TypingIndicator', () => {
const { Text } = require('react-native');
return { TypingIndicator: () => <Text>typing-indicator</Text> };
});
const msg = (id: string, role: ChatMessage['role'], content: string): ChatMessage => ({
id,
role,
content,
timestamp: 0,
});
const handlers = { onEdit: jest.fn(), onDelete: jest.fn(), onRegenerate: jest.fn() };
beforeEach(() => {
Object.keys(mockRenders).forEach((k) => delete mockRenders[k]);
});
describe('MessageList', () => {
it('renders every message', () => {
render(
<MessageList
messages={[msg('u1', 'user', 'q'), msg('a1', 'assistant', 'a')]}
isStreaming={false}
{...handlers}
/>,
);
expect(screen.getByText('u1:q')).toBeTruthy();
expect(screen.getByText('a1:a')).toBeTruthy();
});
it('re-renders only the message that changed while an answer streams in', () => {
const user = msg('u1', 'user', 'question');
const earlier = msg('a0', 'assistant', 'earlier answer');
const streaming = msg('a1', 'assistant', 'He');
const { rerender } = render(
<MessageList messages={[user, earlier, streaming]} isStreaming {...handlers} />,
);
expect(mockRenders).toEqual({ u1: 1, a0: 1, a1: 1 });
rerender(
<MessageList messages={[user, earlier, { ...streaming, content: 'Hello' }]} isStreaming {...handlers} />,
);
expect(screen.getByText('a1:Hello')).toBeTruthy();
expect(mockRenders.u1).toBe(1);
expect(mockRenders.a0).toBe(1);
expect(mockRenders.a1).toBe(2);
});
it('shows the typing indicator while the last assistant message is still empty', () => {
render(
<MessageList messages={[msg('u1', 'user', 'q'), msg('a1', 'assistant', '')]} isStreaming {...handlers} />,
);
expect(screen.getByText('typing-indicator')).toBeTruthy();
});
});
-172
View File
@@ -1,172 +0,0 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';
import SettingsScreen from '../app/settings/index';
import * as api from '../services/lmStudioApi';
import { DEFAULT_SERVER_CONFIG } from '../services/types';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
let mockSystemScheme: 'light' | 'dark' = 'light';
jest.mock('../hooks/useColorScheme', () => ({
useColorScheme: () => mockSystemScheme,
}));
const mockApi = api as jest.Mocked<typeof api>;
const state = () => useAppStore.getState();
const hostInput = () => screen.getByPlaceholderText('localhost');
const portInput = () => screen.getByPlaceholderText('1234');
const keyInput = () => screen.getByPlaceholderText('Leave empty for local');
beforeEach(() => {
jest.resetAllMocks();
mockSystemScheme = 'light';
useAppStore.setState(useAppStore.getInitialState(), true);
mockApi.getModels.mockResolvedValue([]);
});
describe('SettingsScreen connection form', () => {
it('is filled from the stored configuration and hides "Save Changes" until something changes', () => {
useAppStore.setState({ serverConfig: { host: '192.168.0.5', port: 4321, apiKey: 'k' } });
render(<SettingsScreen />);
expect(hostInput().props.value).toBe('192.168.0.5');
expect(portInput().props.value).toBe('4321');
expect(keyInput().props.value).toBe('k');
expect(screen.queryByText('Save Changes')).toBeNull();
});
it('shows "Save Changes" for each changed field', () => {
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), 'other');
expect(screen.getByText('Save Changes')).toBeTruthy();
fireEvent.changeText(hostInput(), DEFAULT_SERVER_CONFIG.host);
expect(screen.queryByText('Save Changes')).toBeNull();
fireEvent.changeText(portInput(), '999');
expect(screen.getByText('Save Changes')).toBeTruthy();
fireEvent.changeText(portInput(), String(DEFAULT_SERVER_CONFIG.port));
expect(screen.queryByText('Save Changes')).toBeNull();
fireEvent.changeText(keyInput(), 'secret');
expect(screen.getByText('Save Changes')).toBeTruthy();
});
it('saves trimmed values', () => {
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), ' 192.168.0.9 ');
fireEvent.changeText(portInput(), '8080');
fireEvent.changeText(keyInput(), ' key ');
fireEvent.press(screen.getByText('Save Changes'));
expect(state().serverConfig).toEqual({ host: '192.168.0.9', port: 8080, apiKey: 'key' });
});
it('falls back to localhost:1234 and no API key when the fields are blank or invalid', () => {
useAppStore.setState({ serverConfig: { host: 'lan', port: 4321, apiKey: 'k' } });
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), ' ');
fireEvent.changeText(portInput(), 'abc');
fireEvent.changeText(keyInput(), ' ');
fireEvent.press(screen.getByText('Save Changes'));
expect(state().serverConfig).toEqual({ host: 'localhost', port: 1234, apiKey: undefined });
});
});
describe('SettingsScreen connection test', () => {
it('saves the configuration, reports success and loads the models', async () => {
mockApi.testConnection.mockResolvedValue(true);
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), '10.0.0.2');
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(mockApi.testConnection).toHaveBeenCalledWith({ host: '10.0.0.2', port: 1234, apiKey: undefined });
expect(state().serverConfig.host).toBe('10.0.0.2');
expect(state().isConnected).toBe(true);
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
expect(screen.getByText('Connection successful. API is ready.')).toBeTruthy();
expect(screen.getByText('Connected')).toBeTruthy();
});
it('reports failure and does not load models', async () => {
mockApi.testConnection.mockResolvedValue(false);
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(state().isConnected).toBe(false);
expect(mockApi.getModels).not.toHaveBeenCalled();
expect(screen.getByText('Connection failed. Verify host and port.')).toBeTruthy();
expect(screen.getByText('Disconnected')).toBeTruthy();
});
it('shows a spinner and ignores presses while the test is running', async () => {
let resolveTest: (ok: boolean) => void = () => {};
mockApi.testConnection.mockReturnValue(new Promise((resolve) => (resolveTest = resolve)));
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(screen.queryByText('Test Connection')).toBeNull();
await act(async () => {
resolveTest(false);
});
expect(screen.getByText('Test Connection')).toBeTruthy();
});
it('clears a previous result when the configuration is saved again', async () => {
mockApi.testConnection.mockResolvedValue(false);
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(screen.getByText(/Connection failed/)).toBeTruthy();
fireEvent.changeText(hostInput(), 'changed');
fireEvent.press(screen.getByText('Save Changes'));
expect(screen.queryByText(/Connection failed/)).toBeNull();
});
});
describe('SettingsScreen dark mode switch', () => {
const darkSwitch = () => screen.UNSAFE_getByType(require('react-native').Switch);
it('is on for the dark preference and switches to light', () => {
useAppStore.setState({ theme: 'dark' });
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(true);
fireEvent(darkSwitch(), 'valueChange', false);
expect(state().theme).toBe('light');
});
it('is off for the light preference and switches to dark', () => {
useAppStore.setState({ theme: 'light' });
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(false);
fireEvent(darkSwitch(), 'valueChange', true);
expect(state().theme).toBe('dark');
});
it('follows the system scheme when the preference is "system"', () => {
mockSystemScheme = 'dark';
const first = render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(true);
first.unmount();
mockSystemScheme = 'light';
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(false);
});
});
-72
View File
@@ -1,72 +0,0 @@
import { createDebouncedStorage } from '../store/debouncedStorage';
const makeBase = () => ({
getItem: jest.fn(async (_name: string): Promise<string | null> => 'from-disk'),
setItem: jest.fn(async (_name: string, _value: string) => {}),
removeItem: jest.fn(async (_name: string) => {}),
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('createDebouncedStorage', () => {
it('collapses a burst of writes into one trailing write with the latest value', () => {
const base = makeBase();
const storage = createDebouncedStorage(base, 400);
storage.setItem('k', '1');
jest.advanceTimersByTime(300);
storage.setItem('k', '2');
jest.advanceTimersByTime(300);
storage.setItem('k', '3');
expect(base.setItem).not.toHaveBeenCalled();
jest.advanceTimersByTime(400);
expect(base.setItem).toHaveBeenCalledTimes(1);
expect(base.setItem).toHaveBeenCalledWith('k', '3');
});
it('reads a pending value before it reaches the disk, and falls back to the base storage', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
await expect(storage.getItem('k')).resolves.toBe('from-disk');
storage.setItem('k', 'fresh');
await expect(storage.getItem('k')).resolves.toBe('fresh');
});
it('flush writes immediately and cancels the timer', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
storage.setItem('a', '1');
storage.setItem('b', '2');
await storage.flush();
expect(base.setItem.mock.calls).toEqual([['a', '1'], ['b', '2']]);
jest.advanceTimersByTime(1000);
expect(base.setItem).toHaveBeenCalledTimes(2);
});
it('flush with nothing pending does not write', async () => {
const base = makeBase();
await createDebouncedStorage(base).flush();
expect(base.setItem).not.toHaveBeenCalled();
});
it('removeItem drops a pending write and removes from the base storage', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
storage.setItem('k', '1');
await storage.removeItem('k');
jest.advanceTimersByTime(1000);
expect(base.setItem).not.toHaveBeenCalled();
expect(base.removeItem).toHaveBeenCalledWith('k');
});
});
-390
View File
@@ -1,390 +0,0 @@
import { Platform } from 'react-native';
import {
buildBaseUrl,
chatCompletion,
chatCompletionStream,
getModels,
parseSSELines,
testConnection,
} from '../services/lmStudioApi';
import { ServerConfig } from '../services/types';
const config: ServerConfig = { host: '192.168.0.10', port: 1234 };
const fetchMock = jest.fn();
const originalFetch = global.fetch;
beforeEach(() => {
fetchMock.mockReset();
global.fetch = fetchMock as unknown as typeof fetch;
});
afterAll(() => {
global.fetch = originalFetch;
});
const sseLine = (content: string) => `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}`;
describe('buildBaseUrl', () => {
it('prepends http:// and appends port and /v1', () => {
expect(buildBaseUrl(config)).toBe('http://192.168.0.10:1234/v1');
});
it('keeps an explicit http or https scheme', () => {
expect(buildBaseUrl({ host: 'http://lan.local', port: 8080 })).toBe('http://lan.local:8080/v1');
expect(buildBaseUrl({ host: 'https://lan.local', port: 443 })).toBe('https://lan.local:443/v1');
});
it('strips trailing slashes from the host', () => {
expect(buildBaseUrl({ host: 'http://lan.local//', port: 1234 })).toBe('http://lan.local:1234/v1');
});
it('does not mistake a host that merely starts with "http" for a scheme', () => {
expect(buildBaseUrl({ host: 'httpbin.local', port: 1234 })).toBe('http://httpbin.local:1234/v1');
});
});
describe('parseSSELines', () => {
it('emits content of every complete data line and keeps the partial tail', () => {
const onChunk = jest.fn();
const onDone = jest.fn();
const result = parseSSELines(`${sseLine('Hel')}\n${sseLine('lo')}\ndata: {"cho`, onChunk, onDone);
expect(onChunk.mock.calls).toEqual([['Hel'], ['lo']]);
expect(result).toEqual({ remaining: 'data: {"cho', isDone: false });
expect(onDone).not.toHaveBeenCalled();
});
it('stops at [DONE] and calls onDone', () => {
const onChunk = jest.fn();
const onDone = jest.fn();
const result = parseSSELines(`${sseLine('a')}\ndata: [DONE]\n${sseLine('ignored')}\n`, onChunk, onDone);
expect(onChunk.mock.calls).toEqual([['a']]);
expect(onDone).toHaveBeenCalledTimes(1);
expect(result).toEqual({ remaining: '', isDone: true });
});
it('skips malformed JSON, empty deltas, comments and non-data lines', () => {
const onChunk = jest.fn();
parseSSELines(
[
'data: {not json}',
'data: {"choices":[{"delta":{}}]}',
'data: {"choices":[]}',
': keep-alive',
'event: message',
'',
sseLine('ok'),
'',
].join('\n'),
onChunk,
jest.fn(),
);
expect(onChunk.mock.calls).toEqual([['ok']]);
});
it('handles CRLF line endings and "data:" without a space', () => {
const onChunk = jest.fn();
const noSpace = `data:${JSON.stringify({ choices: [{ delta: { content: 'nospace' } }] })}`;
parseSSELines(`${sseLine('crlf')}\r\n${noSpace}\n`, onChunk, jest.fn());
expect(onChunk.mock.calls).toEqual([['crlf'], ['nospace']]);
});
});
describe('testConnection', () => {
it('sends the Authorization header only when an API key is set', async () => {
fetchMock.mockResolvedValue({ ok: true });
await testConnection({ ...config, apiKey: 'secret' });
await testConnection({ ...config, apiKey: '' });
expect(fetchMock.mock.calls[0][0]).toBe('http://192.168.0.10:1234/v1/models');
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Bearer secret' });
expect(fetchMock.mock.calls[1][1].headers).not.toHaveProperty('Authorization');
});
it('returns true on a 2xx response and false otherwise', async () => {
fetchMock.mockResolvedValueOnce({ ok: true });
await expect(testConnection(config)).resolves.toBe(true);
fetchMock.mockResolvedValueOnce({ ok: false });
await expect(testConnection(config)).resolves.toBe(false);
});
it('returns false when the network call throws', async () => {
fetchMock.mockRejectedValue(new TypeError('Network request failed'));
await expect(testConnection(config)).resolves.toBe(false);
});
it('aborts after 5 seconds and returns false', async () => {
jest.useFakeTimers();
try {
fetchMock.mockImplementation(
(_url: string, init: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => reject(new Error('aborted')));
}),
);
const pending = testConnection(config);
await jest.advanceTimersByTimeAsync(5000);
await expect(pending).resolves.toBe(false);
} finally {
jest.useRealTimers();
}
});
it('does not leave the timeout timer running after a failure', async () => {
jest.useFakeTimers();
try {
fetchMock.mockRejectedValue(new TypeError('Network request failed'));
await testConnection(config);
expect(jest.getTimerCount()).toBe(0);
} finally {
jest.useRealTimers();
}
});
});
describe('getModels', () => {
it('returns the data array', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ data: [{ id: 'm1' }] }) });
await expect(getModels(config)).resolves.toEqual([{ id: 'm1' }]);
});
it('falls back to an empty list when data is missing', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
await expect(getModels(config)).resolves.toEqual([]);
});
it('throws with the HTTP status when the response is not ok', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' });
await expect(getModels(config)).rejects.toThrow('Failed to fetch models: 401 Unauthorized');
});
});
describe('chatCompletion', () => {
it('posts a non-streaming request with defaults that options can override', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'x' }) });
await chatCompletion(config, 'model-a', [{ role: 'user', content: 'hi' }], { temperature: 0.1 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://192.168.0.10:1234/v1/chat/completions');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({
model: 'model-a',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
temperature: 0.1,
max_tokens: 2048,
});
});
it('throws with the HTTP status when the response is not ok', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' });
await expect(chatCompletion(config, 'm', [])).rejects.toThrow('Chat completion failed: 500 Server Error');
});
});
describe('chatCompletionStream (web, fetch + ReadableStream)', () => {
const originalOS = Platform.OS;
const encoder = new TextEncoder();
beforeEach(() => {
Platform.OS = 'web';
});
afterEach(() => {
Platform.OS = originalOS;
});
const streamOf = (parts: string[]) =>
new ReadableStream<Uint8Array>({
start(controller) {
parts.forEach((p) => controller.enqueue(encoder.encode(p)));
controller.close();
},
});
// Starts a stream and resolves once either onDone or onError fired.
const run = () => {
const onChunk = jest.fn();
const onDone = jest.fn();
const onError = jest.fn();
const finished = new Promise<void>((resolve) => {
chatCompletionStream(
config,
'm',
[],
onChunk,
() => {
onDone();
resolve();
},
(e) => {
onError(e);
resolve();
},
);
});
return { onChunk, onDone, onError, finished };
};
it('streams chunks split across reads and calls onDone exactly once', async () => {
const whole = `${sseLine('Hi')}\n${sseLine(' there')}\ndata: [DONE]\n`;
fetchMock.mockResolvedValue({ ok: true, body: streamOf([whole.slice(0, 20), whole.slice(20)]) });
const { onChunk, onDone, onError, finished } = run();
await finished;
expect(onChunk.mock.calls).toEqual([['Hi'], [' there']]);
expect(onDone).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled();
expect(JSON.parse(fetchMock.mock.calls[0][1].body).stream).toBe(true);
});
it('calls onDone when the stream ends without [DONE]', async () => {
fetchMock.mockResolvedValue({ ok: true, body: streamOf([`${sseLine('x')}\n`]) });
const { onChunk, onDone, finished } = run();
await finished;
expect(onChunk).toHaveBeenCalledWith('x');
expect(onDone).toHaveBeenCalledTimes(1);
});
it('reports an HTTP error through onError', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' });
const { onError, finished } = run();
await finished;
expect(onError.mock.calls[0][0].message).toBe('Stream failed: 404 Not Found');
});
it('turns a network failure into an actionable connection error', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
const { onError, finished } = run();
await finished;
expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1');
});
it('does not report an error when the request is aborted', async () => {
fetchMock.mockImplementation(
(_url: string, init: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
}),
);
const onError = jest.fn();
const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
handle.abort();
await new Promise((r) => setTimeout(r, 0));
expect(onError).not.toHaveBeenCalled();
});
});
describe('chatCompletionStream (native, XMLHttpRequest)', () => {
class FakeXHR {
static last: FakeXHR;
status = 200;
statusText = 'OK';
responseText = '';
headers: Record<string, string> = {};
aborted = false;
onprogress: (() => void) | null = null;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
ontimeout: (() => void) | null = null;
open = jest.fn();
send = jest.fn();
constructor() {
FakeXHR.last = this;
}
setRequestHeader(k: string, v: string) {
this.headers[k] = v;
}
abort() {
this.aborted = true;
}
}
const originalXHR = global.XMLHttpRequest;
beforeEach(() => {
global.XMLHttpRequest = FakeXHR as unknown as typeof XMLHttpRequest;
});
afterEach(() => {
global.XMLHttpRequest = originalXHR;
});
it('parses progress events incrementally and finishes once', () => {
const onChunk = jest.fn();
const onDone = jest.fn();
chatCompletionStream({ ...config, apiKey: 'k' }, 'm', [], onChunk, onDone, jest.fn());
const xhr = FakeXHR.last;
expect(xhr.open).toHaveBeenCalledWith('POST', 'http://192.168.0.10:1234/v1/chat/completions');
expect(xhr.headers.Authorization).toBe('Bearer k');
const full = `${sseLine('A')}\n${sseLine('B')}\n`;
xhr.responseText = full.slice(0, 30);
xhr.onprogress?.();
xhr.responseText = full;
xhr.onprogress?.();
xhr.responseText = `${full}data: [DONE]\n`;
xhr.onprogress?.();
xhr.onload?.();
expect(onChunk.mock.calls).toEqual([['A'], ['B']]);
expect(onDone).toHaveBeenCalledTimes(1);
});
it('flushes a final line that has no trailing newline on load', () => {
const onChunk = jest.fn();
chatCompletionStream(config, 'm', [], onChunk, jest.fn(), jest.fn());
const xhr = FakeXHR.last;
xhr.responseText = sseLine('tail');
xhr.onprogress?.();
xhr.onload?.();
expect(onChunk.mock.calls).toEqual([['tail']]);
});
it('reports an HTTP error status instead of finishing silently', () => {
const onDone = jest.fn();
const onError = jest.fn();
chatCompletionStream(config, 'm', [], jest.fn(), onDone, onError);
const xhr = FakeXHR.last;
xhr.status = 401;
xhr.statusText = 'Unauthorized';
xhr.responseText = '{"error":"bad key"}';
xhr.onprogress?.();
xhr.onload?.();
expect(onError.mock.calls[0][0].message).toBe('Stream failed: 401 Unauthorized');
expect(onDone).not.toHaveBeenCalled();
});
it('turns a network error into an actionable connection error', () => {
const onError = jest.fn();
chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
FakeXHR.last.status = 0;
FakeXHR.last.onerror?.();
expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1');
});
it('reports a timeout', () => {
const onError = jest.fn();
chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
FakeXHR.last.ontimeout?.();
expect(onError.mock.calls[0][0].message).toBe('Request timed out');
});
it('abort() aborts the request and swallows errors thrown by abort', () => {
const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), jest.fn());
const xhr = FakeXHR.last;
handle.abort();
expect(xhr.aborted).toBe(true);
xhr.abort = () => {
throw new Error('boom');
};
expect(() => handle.abort()).not.toThrow();
});
});
-188
View File
@@ -1,188 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { appStorage } from '../store/debouncedStorage';
import { useAppStore } from '../store/useAppStore';
const state = () => useAppStore.getState();
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true);
});
describe('messages', () => {
it('addMessage appends with a generated id and timestamp, and returns the id', () => {
const id = state().addMessage({ role: 'user', content: 'hi' });
const [msg] = state().messages;
expect(msg.id).toBe(id);
expect(msg).toMatchObject({ role: 'user', content: 'hi' });
expect(typeof msg.timestamp).toBe('number');
});
it('generates distinct ids for messages created in the same millisecond', () => {
const ids = new Set(Array.from({ length: 50 }, () => state().addMessage({ role: 'user', content: 'x' })));
expect(ids.size).toBe(50);
});
it('appendToMessage and updateMessageContent only touch the target message', () => {
const a = state().addMessage({ role: 'assistant', content: 'He' });
const b = state().addMessage({ role: 'assistant', content: 'other' });
state().appendToMessage(a, 'llo');
expect(state().messages.map((m) => m.content)).toEqual(['Hello', 'other']);
state().updateMessageContent(b, 'replaced');
expect(state().messages.map((m) => m.content)).toEqual(['Hello', 'replaced']);
});
it('editMessage replaces the content and marks it as edited', () => {
const id = state().addMessage({ role: 'user', content: 'old' });
state().editMessage(id, 'new');
expect(state().messages[0]).toMatchObject({ content: 'new', isEdited: true });
});
it('deleteMessage removes only the given message', () => {
const a = state().addMessage({ role: 'user', content: 'a' });
state().addMessage({ role: 'assistant', content: 'b' });
state().deleteMessage(a);
expect(state().messages.map((m) => m.content)).toEqual(['b']);
});
describe('deleteMessagesAfter', () => {
it('keeps the given message and drops everything after it', () => {
const a = state().addMessage({ role: 'user', content: 'a' });
state().addMessage({ role: 'assistant', content: 'b' });
state().addMessage({ role: 'user', content: 'c' });
state().deleteMessagesAfter(a);
expect(state().messages.map((m) => m.content)).toEqual(['a']);
});
it('does nothing when the id is unknown', () => {
state().addMessage({ role: 'user', content: 'a' });
state().deleteMessagesAfter('missing');
expect(state().messages).toHaveLength(1);
});
});
it('clearChat empties the conversation and forgets the current session', () => {
state().addMessage({ role: 'user', content: 'a' });
state().setCurrentChatId('abc');
state().clearChat();
expect(state().messages).toEqual([]);
expect(state().currentChatId).toBeNull();
});
});
describe('chat sessions', () => {
it('saveCurrentChat ignores an empty conversation', () => {
state().saveCurrentChat();
expect(state().chatSessions).toEqual([]);
expect(state().currentChatId).toBeNull();
});
it('creates a session titled by the first user message and makes it current', () => {
state().addMessage({ role: 'assistant', content: 'welcome' });
state().addMessage({ role: 'user', content: 'first question' });
state().saveCurrentChat();
expect(state().chatSessions).toHaveLength(1);
const [session] = state().chatSessions;
expect(session.title).toBe('first question');
expect(session.messages).toHaveLength(2);
expect(state().currentChatId).toBe(session.id);
});
it('truncates long titles to 40 characters plus an ellipsis', () => {
state().addMessage({ role: 'user', content: 'x'.repeat(60) });
state().saveCurrentChat();
expect(state().chatSessions[0].title).toBe(`${'x'.repeat(40)}...`);
});
it('falls back to "New Chat" when there is no user message', () => {
state().addMessage({ role: 'assistant', content: 'hello' });
state().saveCurrentChat();
expect(state().chatSessions[0].title).toBe('New Chat');
});
it('updates the existing session instead of creating another one', () => {
state().addMessage({ role: 'user', content: 'q' });
state().saveCurrentChat();
const firstId = state().currentChatId;
state().addMessage({ role: 'assistant', content: 'a' });
state().saveCurrentChat();
expect(state().chatSessions).toHaveLength(1);
expect(state().chatSessions[0].id).toBe(firstId);
expect(state().chatSessions[0].messages).toHaveLength(2);
});
it('only rewrites the session that matches currentChatId', () => {
useAppStore.setState({
chatSessions: [
{ id: '1', title: 'one', updatedAt: 1, messages: [] },
{ id: '2', title: 'two', updatedAt: 2, messages: [] },
],
currentChatId: '2',
messages: [{ id: 'm', role: 'user', content: 'changed', timestamp: 3 }],
});
state().saveCurrentChat();
expect(state().chatSessions[0]).toMatchObject({ id: '1', title: 'one' });
expect(state().chatSessions[1]).toMatchObject({ id: '2', title: 'changed' });
});
it('loadChat restores the messages of a session, and ignores unknown ids', () => {
const messages = [{ id: 'm', role: 'user' as const, content: 'saved', timestamp: 1 }];
useAppStore.setState({ chatSessions: [{ id: 's', title: 't', updatedAt: 1, messages }] });
state().loadChat('missing');
expect(state().messages).toEqual([]);
state().loadChat('s');
expect(state().messages).toEqual(messages);
expect(state().currentChatId).toBe('s');
});
it('deleteChatSession resets the open conversation only when it deletes the current one', () => {
const session = (id: string) => ({ id, title: id, updatedAt: 1, messages: [] });
useAppStore.setState({
chatSessions: [session('a'), session('b')],
currentChatId: 'a',
messages: [{ id: 'm', role: 'user', content: 'x', timestamp: 1 }],
});
state().deleteChatSession('b');
expect(state().chatSessions.map((s) => s.id)).toEqual(['a']);
expect(state().messages).toHaveLength(1);
state().deleteChatSession('a');
expect(state().chatSessions).toEqual([]);
expect(state().messages).toEqual([]);
expect(state().currentChatId).toBeNull();
});
});
describe('persistence', () => {
it('persists settings, the selected model and chats but not transient connection or streaming state', async () => {
useAppStore.setState({
theme: 'dark',
serverConfig: { host: 'lan.local', port: 4321, apiKey: 'k' },
isConnected: true,
isStreaming: true,
activeModelId: 'model',
availableModels: [{ id: 'model', object: 'model', owned_by: 'x' }],
editingMessageId: 'e',
});
state().addMessage({ role: 'user', content: 'persist me' });
await appStorage.flush();
const raw = await AsyncStorage.getItem('localaiapp-storage');
const persisted = JSON.parse(raw as string).state;
expect(Object.keys(persisted).sort()).toEqual(
['activeModelId', 'chatSessions', 'currentChatId', 'messages', 'serverConfig', 'theme'],
);
expect(persisted.serverConfig).toEqual({ host: 'lan.local', port: 4321, apiKey: 'k' });
expect(persisted.activeModelId).toBe('model');
expect(persisted.messages[0].content).toBe('persist me');
});
});
-42
View File
@@ -1,42 +0,0 @@
import { act, renderHook } from '@testing-library/react-native';
import { useAutoConnect } from '../hooks/useAutoConnect';
import * as api from '../services/lmStudioApi';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
const mockApi = api as jest.Mocked<typeof api>;
beforeEach(() => {
jest.resetAllMocks();
useAppStore.setState(useAppStore.getInitialState(), true);
});
describe('useAutoConnect', () => {
it('loads the models once on mount and marks the server connected', async () => {
mockApi.getModels.mockResolvedValue([{ id: 'm1', object: 'model', owned_by: 'x' }]);
renderHook(() => useAutoConnect());
await act(async () => {});
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
expect(useAppStore.getState().isConnected).toBe(true);
expect(useAppStore.getState().activeModelId).toBe('m1');
});
it('leaves the app offline when the server does not answer', async () => {
mockApi.getModels.mockRejectedValue(new Error('down'));
renderHook(() => useAutoConnect());
await act(async () => {});
expect(useAppStore.getState().isConnected).toBe(false);
});
it('does not check again on re-render', async () => {
mockApi.getModels.mockResolvedValue([]);
const { rerender } = renderHook(() => useAutoConnect());
await act(async () => {});
rerender({});
await act(async () => {});
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
});
});
-246
View File
@@ -1,246 +0,0 @@
import { act, renderHook } from '@testing-library/react-native';
import { useLMStudio } from '../hooks/useLMStudio';
import * as api from '../services/lmStudioApi';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
const mockApi = api as jest.Mocked<typeof api>;
const state = () => useAppStore.getState();
type StreamArgs = Parameters<typeof api.chatCompletionStream>;
const lastStreamCall = () => mockApi.chatCompletionStream.mock.calls.at(-1) as StreamArgs;
beforeEach(() => {
jest.resetAllMocks();
useAppStore.setState(useAppStore.getInitialState(), true);
useAppStore.setState({ activeModelId: 'model-a' });
mockApi.chatCompletionStream.mockReturnValue({ abort: jest.fn() });
});
describe('checkConnection', () => {
it('stores the result of the connection test', async () => {
mockApi.testConnection.mockResolvedValue(true);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await expect(result.current.checkConnection()).resolves.toBe(true);
});
expect(state().isConnected).toBe(true);
});
});
describe('fetchModels', () => {
const models = [
{ id: 'first', object: 'model', owned_by: 'x' },
{ id: 'second', object: 'model', owned_by: 'x' },
];
it('stores the models, marks the server connected and selects the first model if none is active', async () => {
useAppStore.setState({ activeModelId: '' });
mockApi.getModels.mockResolvedValue(models);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.fetchModels();
});
expect(state().availableModels).toEqual(models);
expect(state().isConnected).toBe(true);
expect(state().activeModelId).toBe('first');
});
it('keeps the model that was already selected', async () => {
mockApi.getModels.mockResolvedValue(models);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.fetchModels();
});
expect(state().activeModelId).toBe('model-a');
});
it('does not select anything when the server has no models', async () => {
useAppStore.setState({ activeModelId: '' });
mockApi.getModels.mockResolvedValue([]);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.fetchModels();
});
expect(state().activeModelId).toBe('');
});
it('marks the server offline and clears the list when the request fails', async () => {
useAppStore.setState({ isConnected: true, availableModels: models });
mockApi.getModels.mockRejectedValue(new Error('down'));
const { result } = renderHook(() => useLMStudio());
let returned: unknown;
await act(async () => {
returned = await result.current.fetchModels();
});
expect(returned).toEqual([]);
expect(state().isConnected).toBe(false);
expect(state().availableModels).toEqual([]);
});
});
describe('sendMessage', () => {
it('streams an answer for the conversation, prefixed by the system prompt', async () => {
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
const [, modelId, apiMessages, onChunk, onDone] = lastStreamCall();
expect(modelId).toBe('model-a');
expect(apiMessages[0].role).toBe('system');
expect(apiMessages.slice(1)).toEqual([{ role: 'user', content: 'hello' }]);
expect(state().isStreaming).toBe(true);
act(() => {
onChunk('Hi');
onChunk(' there');
onDone();
});
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual(['user:hello', 'assistant:Hi there']);
expect(state().isStreaming).toBe(false);
expect(state().chatSessions).toHaveLength(1);
});
it('asks for a model instead of calling the server when none is selected', async () => {
useAppStore.setState({ activeModelId: '' });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
expect(state().messages[1].content).toContain('No model selected');
expect(state().isStreaming).toBe(false);
});
it('ignores new messages while an answer is streaming', async () => {
useAppStore.setState({ isStreaming: true });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('again');
});
expect(state().messages).toEqual([]);
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
});
it('writes the error into the assistant message and stops streaming when the stream fails', async () => {
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
const onError = lastStreamCall()[5];
act(() => {
onError(new Error('Could not reach the server'));
});
expect(state().messages[1].content).toContain('Error: Could not reach the server');
expect(state().isStreaming).toBe(false);
expect(state().chatSessions).toHaveLength(1);
});
it('reports a synchronous failure to start the stream', async () => {
mockApi.chatCompletionStream.mockImplementation(() => {
throw new Error('boom');
});
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
expect(state().messages[1].content).toContain('Connection failed: boom');
expect(state().isStreaming).toBe(false);
});
});
describe('regenerateResponse', () => {
it('drops everything after the last user message and asks again', async () => {
state().addMessage({ role: 'user', content: 'q1' });
state().addMessage({ role: 'assistant', content: 'a1' });
state().addMessage({ role: 'user', content: 'q2' });
state().addMessage({ role: 'assistant', content: 'a2' });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.regenerateResponse();
});
const apiMessages = lastStreamCall()[2];
expect(apiMessages.slice(1).map((m) => m.content)).toEqual(['q1', 'a1', 'q2']);
expect(state().messages.map((m) => m.content)).toEqual(['q1', 'a1', 'q2', '']);
expect(state().isStreaming).toBe(true);
});
it('keeps the current answer and asks for a model when none is selected', async () => {
state().addMessage({ role: 'user', content: 'q' });
state().addMessage({ role: 'assistant', content: 'a' });
useAppStore.setState({ activeModelId: '' });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.regenerateResponse();
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
expect(state().messages.map((m) => m.content)).toEqual([
'q',
'a',
expect.stringContaining('No model selected'),
]);
});
it('does nothing without a user message or while streaming', async () => {
const { result, rerender } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.regenerateResponse();
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
expect(state().messages).toEqual([]);
act(() => {
state().addMessage({ role: 'user', content: 'q' });
});
act(() => {
useAppStore.setState({ activeModelId: 'model-a', isStreaming: true });
});
rerender({});
await act(async () => {
await result.current.regenerateResponse();
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
});
});
describe('stopStreaming', () => {
it('aborts the request, stops streaming and keeps the partial answer in History', async () => {
const abort = jest.fn();
mockApi.chatCompletionStream.mockReturnValue({ abort });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
act(() => {
lastStreamCall()[3]('partial');
});
act(() => {
result.current.stopStreaming();
});
expect(abort).toHaveBeenCalledTimes(1);
expect(state().isStreaming).toBe(false);
expect(state().chatSessions[0].messages.at(-1)?.content).toBe('partial');
});
it('is a no-op when nothing is streaming', () => {
const { result } = renderHook(() => useLMStudio());
act(() => {
result.current.stopStreaming();
});
expect(state().chatSessions).toEqual([]);
});
});
-77
View File
@@ -1,77 +0,0 @@
import { ChatMessage } from '../store/useAppStore';
import { findLastAssistantId, shouldShowTypingIndicator } from '../utils/chatSelectors';
import { buildServerConfig, isConfigChanged } from '../utils/serverConfig';
import { isDarkTheme } from '../utils/theme';
const msg = (id: string, role: ChatMessage['role'], content = 'x'): ChatMessage => ({
id,
role,
content,
timestamp: 0,
});
describe('findLastAssistantId', () => {
it('returns the most recent assistant message', () => {
expect(findLastAssistantId([msg('1', 'assistant'), msg('2', 'user'), msg('3', 'assistant')])).toBe('3');
});
it('returns null when there is no assistant message', () => {
expect(findLastAssistantId([msg('1', 'user')])).toBeNull();
expect(findLastAssistantId([])).toBeNull();
});
});
describe('shouldShowTypingIndicator', () => {
it('is true only while streaming an assistant message with no text yet', () => {
expect(shouldShowTypingIndicator([msg('1', 'user'), msg('2', 'assistant', '')], true)).toBe(true);
});
it('is false when not streaming, when text arrived, when the last message is the user, or when empty', () => {
expect(shouldShowTypingIndicator([msg('2', 'assistant', '')], false)).toBe(false);
expect(shouldShowTypingIndicator([msg('2', 'assistant', 'hi')], true)).toBe(false);
expect(shouldShowTypingIndicator([msg('1', 'user', '')], true)).toBe(false);
expect(shouldShowTypingIndicator([], true)).toBe(false);
});
});
describe('buildServerConfig', () => {
it('trims the host and API key and parses the port', () => {
expect(buildServerConfig({ host: ' 10.0.0.1 ', port: '8080', apiKey: ' k ' })).toEqual({
host: '10.0.0.1',
port: 8080,
apiKey: 'k',
});
});
it('uses the defaults for blank host, invalid port and blank key', () => {
expect(buildServerConfig({ host: '', port: 'abc', apiKey: ' ' })).toEqual({
host: 'localhost',
port: 1234,
apiKey: undefined,
});
});
});
describe('isConfigChanged', () => {
const saved = { host: 'lan', port: 1234, apiKey: 'k' };
it('is false when the form matches the saved config', () => {
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: 'k' }, saved)).toBe(false);
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, { host: 'lan', port: 1234 })).toBe(false);
});
it('is true when any single field differs', () => {
expect(isConfigChanged({ host: 'other', port: '1234', apiKey: 'k' }, saved)).toBe(true);
expect(isConfigChanged({ host: 'lan', port: '99', apiKey: 'k' }, saved)).toBe(true);
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, saved)).toBe(true);
});
});
describe('isDarkTheme', () => {
it('honours an explicit preference and falls back to the system scheme', () => {
expect(isDarkTheme('dark', 'light')).toBe(true);
expect(isDarkTheme('light', 'dark')).toBe(false);
expect(isDarkTheme('system', 'dark')).toBe(true);
expect(isDarkTheme('system', 'light')).toBe(false);
});
});
+8 -14
View File
@@ -1,17 +1,19 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
import { StyleSheet } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}>
<Text style={styles.title}>This screen does not exist.</Text>
<ThemedView style={styles.container}>
<ThemedText type="title">This screen doesn't exist.</ThemedText>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen</Text>
<ThemedText type="link">Go to home screen!</ThemedText>
</Link>
</View>
</ThemedView>
</>
);
}
@@ -23,16 +25,8 @@ const styles = StyleSheet.create({
justifyContent: 'center',
padding: 20,
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
marginTop: 15,
paddingVertical: 15,
},
linkText: {
fontSize: 14,
color: '#2e78b7',
},
});
-8
View File
@@ -1,5 +1,4 @@
import { ModelSelector } from '@/components/chat/ModelSelector';
import { useAutoConnect } from '@/hooks/useAutoConnect';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import { Stack, useRouter } from 'expo-router';
@@ -11,7 +10,6 @@ export default function ChatLayout() {
const router = useRouter();
const clearChat = useAppStore(state => state.clearChat);
const colors = useThemeColor();
useAutoConnect();
const isLargeScreen = width > 768;
const containerStyle = [
@@ -38,8 +36,6 @@ export default function ChatLayout() {
<View style={{ flexDirection: 'row', gap: 8 }}>
<TouchableOpacity
onPress={() => router.push('/chat/history')}
accessibilityRole="button"
accessibilityLabel="Chat history"
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7}
>
@@ -47,8 +43,6 @@ export default function ChatLayout() {
</TouchableOpacity>
<TouchableOpacity
onPress={clearChat}
accessibilityRole="button"
accessibilityLabel="New chat"
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7}
>
@@ -59,8 +53,6 @@ export default function ChatLayout() {
headerRight: () => (
<TouchableOpacity
onPress={() => router.push('/settings')}
accessibilityRole="button"
accessibilityLabel="Settings"
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7}
>
+1 -10
View File
@@ -20,12 +20,7 @@ export default function HistoryScreen() {
return (
<View style={[styles.container, { backgroundColor: colors.backgroundColor }]}>
<View style={[styles.header, { borderBottomColor: colors.border, backgroundColor: colors.backgroundColor }]}>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"
>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<ArrowLeft size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>Chat History</Text>
@@ -48,8 +43,6 @@ export default function HistoryScreen() {
{ backgroundColor: colors.cardBackground, borderColor: colors.border }
]}
onPress={() => handleLoadChat(item.id)}
accessibilityRole="button"
accessibilityLabel={`Open chat: ${item.title}`}
>
<View style={styles.chatInfo}>
<Text style={[styles.chatTitle, { color: colors.text }]} numberOfLines={1}>
@@ -62,8 +55,6 @@ export default function HistoryScreen() {
<TouchableOpacity
style={styles.deleteButton}
onPress={() => deleteChatSession(item.id)}
accessibilityRole="button"
accessibilityLabel={`Delete chat: ${item.title}`}
>
<Trash2 size={20} color={colors.errorText} />
</TouchableOpacity>
+193 -17
View File
@@ -1,12 +1,20 @@
import { ChatInput } from '@/components/chat/ChatInput';
import { EmptyState } from '@/components/chat/EmptyState';
import { MessageList } from '@/components/chat/MessageList';
import { OfflineBanner } from '@/components/chat/OfflineBanner';
import { MessageBubble } from '@/components/chat/MessageBubble';
import { TypingIndicator } from '@/components/chat/TypingIndicator';
import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import React, { useCallback } from 'react';
import { KeyboardAvoidingView, Platform, StyleSheet } from 'react-native';
import { MessageSquare, Sparkles } from 'lucide-react-native';
import React, { useCallback, useEffect, useRef } from 'react';
import { FlatList, KeyboardAvoidingView, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
const PROMPT_SUGGESTIONS = [
'Explain recursion in simple terms',
'Write a short poem about coding',
'What are the best practices for React Native?',
'Help me debug a JavaScript error',
];
export default function ChatScreen() {
const colors = useThemeColor(); // Dynamic theme
@@ -17,6 +25,18 @@ export default function ChatScreen() {
const deleteMessage = useAppStore(state => state.deleteMessage);
const editMessage = useAppStore(state => state.editMessage);
const { sendMessage, stopStreaming, regenerateResponse, isStreaming, isConnected } = useLMStudio();
const flatListRef = useRef<FlatList>(null);
const isUserScrollingRef = useRef(false);
const isAutoScrollEnabled = useRef(true);
const prevMessagesLength = useRef(messages.length);
const handleScroll = useCallback((event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
// if user is within 100 pixels of the bottom, it's considered at the bottom
const paddingToBottom = 100;
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom;
isAutoScrollEnabled.current = isCloseToBottom;
}, []);
// Find the editing message content
const editingMessage = editingMessageId
@@ -28,35 +48,128 @@ export default function ChatScreen() {
editMessage(editingMessageId, text);
deleteMessagesAfter(editingMessageId);
setEditingMessageId(null);
// The edited message is now the last one, so regenerating answers it.
regenerateResponse();
setTimeout(() => {
const store = useAppStore.getState();
const msgs = store.messages;
// Add system prompt before mapped messages
const { SYSTEM_PROMPT } = require('@/constants/SystemPrompt');
const apiMsgs = [
{ role: 'system', content: SYSTEM_PROMPT },
...msgs.map(m => ({ role: m.role, content: m.content }))
];
const modelId = store.activeModelId;
if (!modelId) return;
const assistantId = store.addMessage({ role: 'assistant', content: '' });
store.setIsStreaming(true);
const { chatCompletionStream } = require('@/services/lmStudioApi');
chatCompletionStream(
store.serverConfig,
modelId,
apiMsgs,
(chunk: string) => store.appendToMessage(assistantId, chunk),
() => { store.setIsStreaming(false); },
(error: Error) => {
store.appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
store.setIsStreaming(false);
},
);
}, 100);
} else {
sendMessage(text);
isAutoScrollEnabled.current = true; // explicitly re-enable on manual send
}
}, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, regenerateResponse, sendMessage]);
}, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, sendMessage]);
const handleEdit = useCallback((id: string) => setEditingMessageId(id), [setEditingMessageId]);
const handleCancelEdit = useCallback(() => setEditingMessageId(null), [setEditingMessageId]);
const handleDelete = useCallback((id: string) => deleteMessage(id), [deleteMessage]);
const lastAssistantId = messages.length > 0
? [...messages].reverse().find(m => m.role === 'assistant')?.id
: null;
useEffect(() => {
const isNewMessage = messages.length > prevMessagesLength.current;
prevMessagesLength.current = messages.length;
if (messages.length > 0 && flatListRef.current) {
setTimeout(() => {
if ((isAutoScrollEnabled.current && !isUserScrollingRef.current) || isNewMessage) {
flatListRef.current?.scrollToEnd({ animated: true });
}
}, 100);
}
}, [messages.length, messages[messages.length - 1]?.content?.length]);
return (
<KeyboardAvoidingView
style={[styles.container, { backgroundColor: colors.backgroundColor }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
{!isConnected && <OfflineBanner />}
{!isConnected && (
<View style={[styles.banner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.bannerBg }]}>
<Text style={[styles.bannerText, { color: colors.bannerText }]}>
⚠️ Server offline. Check Settings → Server Connection.
</Text>
</View>
)}
{messages.length === 0 ? (
<EmptyState onSelectPrompt={sendMessage} />
<View style={[styles.emptyState, { backgroundColor: colors.backgroundColor }]}>
<View style={[styles.emptyIconWrap, { backgroundColor: colors.cardBackground, shadowColor: colors.shadow }]}>
<MessageSquare size={40} color={colors.textMuted} />
</View>
<Text style={[styles.emptyTitle, { color: colors.text }]}>Start a conversation</Text>
<Text style={[styles.emptySubtitle, { color: colors.textMuted }]}>
Send a message to begin chatting with your local AI model.
</Text>
<View style={styles.suggestionsWrap}>
{PROMPT_SUGGESTIONS.map((prompt, i) => (
<TouchableOpacity
key={i}
style={[styles.suggestionChip, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}
onPress={() => sendMessage(prompt)}
activeOpacity={0.7}
>
<Sparkles size={14} color={colors.textMuted} />
<Text style={[styles.suggestionText, { color: colors.text }]} numberOfLines={1}>{prompt}</Text>
</TouchableOpacity>
))}
</View>
</View>
) : (
<MessageList
messages={messages}
isStreaming={isStreaming}
onEdit={handleEdit}
onDelete={handleDelete}
onRegenerate={regenerateResponse}
/>
<>
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.id}
onScroll={handleScroll}
onScrollBeginDrag={() => isUserScrollingRef.current = true}
onScrollEndDrag={() => isUserScrollingRef.current = false}
scrollEventThrottle={16}
renderItem={({ item, index }) => (
<Animated.View entering={FadeInDown.duration(300).delay(index > messages.length - 4 ? 100 : 0)}>
<MessageBubble
message={item}
onEdit={() => handleEdit(item.id)}
onRegenerate={regenerateResponse}
onDelete={() => handleDelete(item.id)}
isStreaming={isStreaming}
isLastAssistant={item.id === lastAssistantId}
/>
</Animated.View>
)}
contentContainerStyle={styles.listContent}
/>
</>
)}
<ChatInput
@@ -74,4 +187,67 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
listContent: {
paddingTop: 16,
paddingBottom: 24,
},
banner: {
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: 1,
},
bannerText: {
fontSize: 13,
textAlign: 'center',
fontWeight: '500',
},
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
paddingBottom: 80,
},
emptyIconWrap: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
emptyTitle: {
fontSize: 22,
fontWeight: '700',
marginBottom: 8,
},
emptySubtitle: {
fontSize: 15,
textAlign: 'center',
lineHeight: 22,
marginBottom: 32,
},
suggestionsWrap: {
width: '100%',
maxWidth: 400,
gap: 12,
},
suggestionChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
},
suggestionText: {
fontSize: 15,
flex: 1,
fontWeight: '500',
},
});
+338 -8
View File
@@ -1,14 +1,76 @@
import { AboutCard } from '@/components/settings/AboutCard';
import { AppearanceCard } from '@/components/settings/AppearanceCard';
import { ServerConnectionCard } from '@/components/settings/ServerConnectionCard';
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react';
import { ScrollView, StyleSheet, useWindowDimensions } from 'react-native';
import * as api from '@/services/lmStudioApi';
import { ServerConfig } from '@/services/types';
import { useAppStore } from '@/store/useAppStore';
import { Info, MonitorCog, Moon, Network } from 'lucide-react-native';
import React, { useState } from 'react';
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
useWindowDimensions,
View,
} from 'react-native';
export default function SettingsScreen() {
const { width } = useWindowDimensions();
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 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 (
<ScrollView
@@ -18,9 +80,151 @@ export default function SettingsScreen() {
isLargeScreen && styles.desktopContent,
]}
>
<ServerConnectionCard />
<AppearanceCard />
<AboutCard />
{/* Server Connection Section */}
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>SERVER CONNECTION</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
<View style={styles.cardHeader}>
<View style={styles.cardHeaderTitle}>
<Network size={20} color={colors.text} style={styles.headerIcon} />
<Text style={[styles.cardTitle, { color: colors.text }]}>LM Studio API</Text>
</View>
<ConnectionBadge isConnected={isConnected} />
</View>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>Host IP / Address</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
]}
value={host}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>Port Number</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
]}
value={port}
onChangeText={setPort}
placeholder="1234"
placeholderTextColor={colors.textMuted}
keyboardType="numeric"
/>
</View>
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>API Key (optional)</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
]}
value={apiKey}
onChangeText={setApiKey}
placeholder="Leave empty for local"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
/>
</View>
<View style={styles.buttonRow}>
{configChanged && (
<TouchableOpacity
style={[styles.saveButton, { borderColor: colors.border, backgroundColor: colors.cardBackground }]}
onPress={handleSaveConfig}
>
<Text style={[styles.saveButtonText, { color: colors.text }]}>Save Changes</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[
styles.testButton,
{ backgroundColor: colors.tint },
isTesting && styles.testButtonDisabled
]}
onPress={handleTestConnection}
disabled={isTesting}
>
{isTesting ? (
<ActivityIndicator size="small" color={colors.backgroundColor} />
) : (
<Text style={[styles.testButtonText, { color: colors.backgroundColor }]}>Test Connection</Text>
)}
</TouchableOpacity>
</View>
{testResult !== null && (
<View style={[
styles.resultBox,
{ backgroundColor: testResult ? colors.successBg : colors.errorBg }
]}>
<Text style={[
styles.resultText,
{ color: testResult ? colors.successText : colors.errorText }
]}>
{testResult
? 'Connection successful. API is ready.'
: 'Connection failed. Verify host and port.'}
</Text>
</View>
)}
</View>
</View>
{/* Appearance Section */}
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>APPEARANCE</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
<View style={styles.row}>
<View style={styles.rowIconWrap}>
<Moon size={20} color={colors.text} />
</View>
<View style={styles.rowText}>
<Text style={[styles.rowTitle, { color: colors.text }]}>Dark Mode</Text>
<Text style={[styles.rowSubtitle, { color: colors.textMuted }]}>Toggle sleek dark theme</Text>
</View>
<Switch
value={isDarkMode}
onValueChange={toggleTheme}
trackColor={{ false: '#E2E8F0', true: colors.tint }}
thumbColor={'#FFFFFF'}
/>
</View>
</View>
</View>
{/* About Section */}
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>ABOUT</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
<View style={styles.rowGroup}>
<Info size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.text }]}>LocalAiApp - 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>
);
}
@@ -37,4 +241,130 @@ const styles = StyleSheet.create({
alignItems: 'center',
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,
},
});
-16
View File
@@ -1,16 +0,0 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
[
'babel-preset-expo',
{
// Dependencias (zustand/middleware) usam `import.meta.env`, que nao existe
// no bundle web do Metro (script classico) e quebrava a hidratacao do app.
// O polyfill do Expo troca por `globalThis.__ExpoImportMetaRegistry`.
unstable_transformImportMeta: true,
},
],
],
};
};
+28 -13
View File
@@ -1,6 +1,7 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import * as Haptics from 'expo-haptics';
import { Send, Square, X } from 'lucide-react-native';
import { Globe, Send, Square, X } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import {
Platform,
@@ -22,6 +23,8 @@ interface ChatInputProps {
export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit }: ChatInputProps) {
const [text, setText] = useState('');
const colors = useThemeColor();
const isWebSearchEnabled = useAppStore(state => state.isWebSearchEnabled);
const setIsWebSearchEnabled = useAppStore(state => state.setIsWebSearchEnabled);
// When editingText changes, fill the input
useEffect(() => {
@@ -65,12 +68,7 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
{isEditing && (
<View style={[styles.editBanner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.border }]}>
<Text style={[styles.editBannerText, { color: colors.bannerText }]}>Editing message</Text>
<TouchableOpacity
onPress={handleCancelEdit}
style={styles.editCancelBtn}
accessibilityRole="button"
accessibilityLabel="Cancel editing"
>
<TouchableOpacity onPress={handleCancelEdit} style={styles.editCancelBtn}>
<X size={16} color={colors.textMuted} />
</TouchableOpacity>
</View>
@@ -82,10 +80,23 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
{ backgroundColor: colors.inputBg, borderColor: colors.border, borderWidth: 1 },
isEditing && { borderColor: colors.tint, borderWidth: 1.5 }
]}>
<TouchableOpacity
style={styles.webSearchButton}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setIsWebSearchEnabled(!isWebSearchEnabled);
}}
activeOpacity={0.7}
>
<Globe
size={20}
color={isWebSearchEnabled ? colors.tint : colors.textMuted}
/>
</TouchableOpacity>
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Send a message..."
accessibilityLabel="Message"
placeholderTextColor={colors.textMuted}
value={text}
onChangeText={setText}
@@ -100,8 +111,6 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
style={[styles.stopButton, { backgroundColor: colors.errorText }]}
onPress={handleStop}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Stop generating"
>
<Square size={16} color={colors.backgroundColor} />
</TouchableOpacity>
@@ -114,9 +123,6 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
onPress={handleSend}
disabled={!text.trim()}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Send message"
accessibilityState={{ disabled: !text.trim() }}
>
<Send size={18} color={text.trim() ? colors.backgroundColor : colors.textMuted} />
</TouchableOpacity>
@@ -175,6 +181,15 @@ const styles = StyleSheet.create({
marginLeft: 8,
marginBottom: 2,
},
webSearchButton: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
marginRight: 4,
marginBottom: 2,
},
stopButton: {
width: 34,
height: 34,
-77
View File
@@ -1,77 +0,0 @@
import { Check, Copy } from 'lucide-react-native';
import React from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface CodeBlockProps {
language: string;
content: string;
copied: boolean;
onCopy: (text: string) => void;
}
export function CodeBlock({ language, content, copied, onCopy }: CodeBlockProps) {
const CopyIcon = copied ? Check : Copy;
return (
<View style={styles.codeBlockWrapper}>
<View style={styles.codeBlockHeader}>
<Text style={styles.codeBlockLanguage}>{language}</Text>
<TouchableOpacity
onPress={() => onCopy(content)}
style={styles.copyButton}
accessibilityRole="button"
>
<CopyIcon size={14} color="#A0A0A0" />
<Text style={styles.copyText}>{copied ? 'Copied!' : 'Copy'}</Text>
</TouchableOpacity>
</View>
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
codeBlockWrapper: {
backgroundColor: '#1E1E1E',
borderRadius: 8,
marginVertical: 8,
overflow: 'hidden',
width: '100%',
},
codeBlockHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#2D2D2D',
paddingHorizontal: 12,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#404040',
},
codeBlockLanguage: {
color: '#A0A0A0',
fontSize: 12,
fontFamily: 'monospace',
textTransform: 'lowercase',
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
padding: 4,
},
copyText: {
color: '#A0A0A0',
fontSize: 12,
},
codeScrollView: {
padding: 12,
},
codeText: {
color: '#D4D4D4',
fontSize: 13,
fontFamily: 'monospace',
},
});
-99
View File
@@ -1,99 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { MessageSquare, Sparkles } from 'lucide-react-native';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
const PROMPT_SUGGESTIONS = [
'Explain recursion in simple terms',
'Write a short poem about coding',
'What are the best practices for React Native?',
'Help me debug a JavaScript error',
];
interface EmptyStateProps {
onSelectPrompt: (prompt: string) => void;
}
export function EmptyState({ onSelectPrompt }: EmptyStateProps) {
const colors = useThemeColor();
return (
<View style={[styles.emptyState, { backgroundColor: colors.backgroundColor }]}>
<View style={[styles.emptyIconWrap, { backgroundColor: colors.cardBackground, shadowColor: colors.shadow }]}>
<MessageSquare size={40} color={colors.textMuted} />
</View>
<Text style={[styles.emptyTitle, { color: colors.text }]}>Start a conversation</Text>
<Text style={[styles.emptySubtitle, { color: colors.textMuted }]}>
Send a message to begin chatting with your local AI model.
</Text>
<View style={styles.suggestionsWrap}>
{PROMPT_SUGGESTIONS.map((prompt) => (
<TouchableOpacity
key={prompt}
style={[styles.suggestionChip, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}
onPress={() => onSelectPrompt(prompt)}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={prompt}
>
<Sparkles size={14} color={colors.textMuted} />
<Text style={[styles.suggestionText, { color: colors.text }]} numberOfLines={1}>{prompt}</Text>
</TouchableOpacity>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
paddingBottom: 80,
},
emptyIconWrap: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
emptyTitle: {
fontSize: 22,
fontWeight: '700',
marginBottom: 8,
},
emptySubtitle: {
fontSize: 15,
textAlign: 'center',
lineHeight: 22,
marginBottom: 32,
},
suggestionsWrap: {
width: '100%',
maxWidth: 400,
gap: 12,
},
suggestionChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
},
suggestionText: {
fontSize: 15,
flex: 1,
fontWeight: '500',
},
});
+4 -4
View File
@@ -50,24 +50,24 @@ export function MessageActions({
return (
<View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}>
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Copy message">
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6}>
<Copy size={16} color={colors.textMuted} />
</TouchableOpacity>
{isUser && onEdit && (
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Edit message">
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6}>
<Pencil size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
{!isUser && onRegenerate && (
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Regenerate response">
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6}>
<RefreshCw size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
{onDelete && (
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Delete message">
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6}>
<Trash2 size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
+342 -63
View File
@@ -1,10 +1,12 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { ChatMessage } from '@/store/useAppStore';
import { Bot, User } from 'lucide-react-native';
import React, { useState } from 'react';
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
import Markdown from '@ronradtke/react-native-markdown-display';
import * as Clipboard from 'expo-clipboard';
import { Bot, Check, Copy, User } from 'lucide-react-native';
import React, { useMemo, useState, useEffect } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
import { MessageActions } from './MessageActions';
import { MessageContent } from './MessageContent';
import { ThinkingBlock } from './ThinkingBlock';
interface MessageBubbleProps {
message: ChatMessage;
@@ -15,58 +17,6 @@ interface MessageBubbleProps {
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({
message,
onEdit,
@@ -75,24 +25,285 @@ export function MessageBubble({
isStreaming,
isLastAssistant,
}: MessageBubbleProps) {
const { width } = useWindowDimensions();
const colors = useThemeColor();
const isLargeScreen = width > 768;
const isUser = message.role === 'user';
const [showActions, setShowActions] = useState(false);
const [copiedText, setCopiedText] = useState<string | null>(null);
const showingStreaming = !!isStreaming && !!isLastAssistant;
const actionsVisible = (!isUser || showActions) && message.content.length > 0;
const handleCopy = async (text: string) => {
await Clipboard.setStringAsync(text);
setCopiedText(text);
setTimeout(() => setCopiedText(null), 2000);
};
const showingStreaming = isStreaming && isLastAssistant;
const shouldShowActions = !isUser || showActions;
const [displayedContent, setDisplayedContent] = useState(message.content);
useEffect(() => {
if (!showingStreaming) {
setDisplayedContent(message.content);
return;
}
let animationFrameId: number;
let currentText = displayedContent;
const targetText = message.content;
if (targetText.length < currentText.length) {
setDisplayedContent(targetText);
return;
}
const animate = () => {
if (currentText.length < targetText.length) {
const diff = targetText.length - currentText.length;
const charsToAdd = Math.max(1, Math.ceil(diff / 4));
currentText = targetText.slice(0, currentText.length + charsToAdd);
setDisplayedContent(currentText);
animationFrameId = requestAnimationFrame(animate);
}
};
animationFrameId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrameId);
}, [message.content, showingStreaming]);
// 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 = () => {
let activeContent = showingStreaming ? displayedContent : message.content;
if (showingStreaming && displayedContent === '' && message.content !== '') {
// Prevent empty bubble flicker on the first chunk
activeContent = message.content.slice(0, 1);
}
if (isUser) {
return (
<Text style={[styles.text, { color: colors.messageUserText }]}>
{activeContent}
</Text>
);
}
if (!activeContent && showingStreaming) {
return (
<View style={styles.dotsRow}>
<View style={[styles.dot, styles.dot1, { backgroundColor: colors.textMuted }]} />
<View style={[styles.dot, styles.dot2, { backgroundColor: colors.textMuted }]} />
<View style={[styles.dot, styles.dot3, { backgroundColor: colors.textMuted }]} />
</View>
);
}
const thinkMatch = activeContent.match(/<think>([\s\S]*?)(?:<\/think>|$)/);
let cleanContent = activeContent;
let thinkingContent = null;
let isThinkingComplete = false;
if (thinkMatch) {
thinkingContent = thinkMatch[1].trim();
isThinkingComplete = activeContent.includes('</think>');
cleanContent = activeContent.replace(thinkMatch[0], '').trim();
}
return (
<>
{thinkingContent !== null && (
<ThinkingBlock
content={thinkingContent}
isComplete={isThinkingComplete}
isStreaming={showingStreaming}
/>
)}
{cleanContent.length > 0 && (
<Markdown style={markdownStyles} rules={markdownRules}>
{cleanContent}
</Markdown>
)}
</>
);
};
return (
<View
onTouchStart={() => isUser && setShowActions(true)}
style={styles.outerWrap}
>
<BubbleRow message={message} showingStreaming={showingStreaming} />
<BubbleMeta message={message} />
<View style={[
styles.container,
isUser ? styles.containerUser : styles.containerAssistant,
]}>
{/* Avatar */}
{!isUser && (
<View style={[styles.avatarAssistant, { backgroundColor: colors.border }]}>
<Bot size={16} color={colors.text} />
</View>
)}
{actionsVisible && (
<View style={[
styles.bubble,
isUser ? styles.bubbleUser : styles.bubbleAssistant,
{ maxWidth: isLargeScreen ? '70%' : '85%' },
{ backgroundColor: isUser ? colors.messageUserBg : colors.messageAssistantBg },
!isUser && { borderWidth: 1, borderColor: colors.border }
]}>
{renderContent()}
</View>
{isUser && (
<View style={[styles.avatarUser, { backgroundColor: colors.tint }]}>
<User size={16} color={colors.backgroundColor} />
</View>
)}
</View>
{/* Timestamp + edited badge */}
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
{!isUser && <View style={{ width: 32 }} />}
<Text style={[styles.timestamp, { color: colors.textMuted }]}>
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{message.isEdited ? ' · edited' : ''}
</Text>
</View>
{/* Action buttons */}
{(shouldShowActions || !isUser) && (showingStreaming ? displayedContent : message.content).length > 0 && (
<MessageActions
role={message.role}
content={message.content}
content={showingStreaming ? displayedContent : message.content}
onEdit={isUser ? onEdit : undefined}
onRegenerate={!isUser && isLastAssistant ? onRegenerate : undefined}
onDelete={onDelete}
@@ -121,7 +332,15 @@ const styles = StyleSheet.create({
containerAssistant: {
justifyContent: 'flex-start',
},
avatar: {
avatarAssistant: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 2,
},
avatarUser: {
width: 32,
height: 32,
borderRadius: 16,
@@ -142,6 +361,10 @@ const styles = StyleSheet.create({
bubbleAssistant: {
borderBottomLeftRadius: 6,
},
text: {
fontSize: 15,
lineHeight: 22,
},
metaRow: {
flexDirection: 'row',
paddingHorizontal: 16,
@@ -159,4 +382,60 @@ const styles = StyleSheet.create({
fontSize: 11,
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',
},
dotsRow: {
flexDirection: 'row',
gap: 6,
alignItems: 'center',
minHeight: 22,
paddingHorizontal: 4,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
},
dot1: { opacity: 1.0 },
dot2: { opacity: 0.6 },
dot3: { opacity: 0.3 },
});
-73
View File
@@ -1,73 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { ChatMessage } from '@/store/useAppStore';
import Markdown from '@ronradtke/react-native-markdown-display';
import * as Clipboard from 'expo-clipboard';
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text } from 'react-native';
import { CodeBlock } from './CodeBlock';
import { createMarkdownStyles } from './markdownStyles';
interface MessageContentProps {
message: ChatMessage;
/** True while this message is the answer currently being streamed. */
showingStreaming: boolean;
}
export function MessageContent({ message, showingStreaming }: MessageContentProps) {
const colors = useThemeColor();
const [copiedText, setCopiedText] = useState<string | null>(null);
const handleCopy = async (text: string) => {
await Clipboard.setStringAsync(text);
setCopiedText(text);
setTimeout(() => setCopiedText(null), 2000);
};
const markdownStyles = useMemo(() => createMarkdownStyles(colors), [colors]);
const markdownRules = useMemo(() => ({
fence: (node: any) => (
<CodeBlock
key={node.key}
language={node.sourceInfo || 'code'}
content={node.content || ''}
copied={copiedText === (node.content || '')}
onCopy={handleCopy}
/>
),
code_block: (node: any) => (
<CodeBlock
key={node.key}
language="code"
content={node.content || ''}
copied={copiedText === (node.content || '')}
onCopy={handleCopy}
/>
),
}), [copiedText]);
if (message.role === 'user') {
return (
<Text style={[styles.text, { color: colors.messageUserText }]}>
{message.content}
</Text>
);
}
if (!message.content && showingStreaming) {
return null; // TypingIndicator handled externally
}
return (
<Markdown style={markdownStyles} rules={markdownRules}>
{message.content}
</Markdown>
);
}
const styles = StyleSheet.create({
text: {
fontSize: 15,
lineHeight: 22,
},
});
-94
View File
@@ -1,94 +0,0 @@
import { MessageBubble } from '@/components/chat/MessageBubble';
import { TypingIndicator } from '@/components/chat/TypingIndicator';
import { ChatMessage } from '@/store/useAppStore';
import { findLastAssistantId, shouldShowTypingIndicator } from '@/utils/chatSelectors';
import React, { useEffect, useRef } from 'react';
import { FlatList, StyleSheet } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
interface MessageListProps {
messages: ChatMessage[];
isStreaming: boolean;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onRegenerate: () => void;
}
interface MessageRowProps {
message: ChatMessage;
delayed: boolean;
isStreaming: boolean;
isLastAssistant: boolean;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onRegenerate: () => void;
}
// Memoized so that a streamed token only re-renders (and re-parses the Markdown of)
// the message that changed, not the whole conversation.
const MessageRow = React.memo(function MessageRow({
message,
delayed,
isStreaming,
isLastAssistant,
onEdit,
onDelete,
onRegenerate,
}: MessageRowProps) {
return (
<Animated.View entering={FadeInDown.duration(300).delay(delayed ? 100 : 0)}>
<MessageBubble
message={message}
onEdit={() => onEdit(message.id)}
onRegenerate={onRegenerate}
onDelete={() => onDelete(message.id)}
isStreaming={isStreaming}
isLastAssistant={isLastAssistant}
/>
</Animated.View>
);
});
export function MessageList({ messages, isStreaming, onEdit, onDelete, onRegenerate }: MessageListProps) {
const flatListRef = useRef<FlatList>(null);
const lastAssistantId = findLastAssistantId(messages);
const lastContentLength = messages[messages.length - 1]?.content?.length;
useEffect(() => {
if (messages.length > 0 && flatListRef.current) {
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: !isStreaming });
}, 100);
}
}, [messages.length, lastContentLength, isStreaming]);
return (
<>
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item, index }) => (
<MessageRow
message={item}
delayed={index > messages.length - 4}
isStreaming={isStreaming}
isLastAssistant={item.id === lastAssistantId}
onEdit={onEdit}
onDelete={onDelete}
onRegenerate={onRegenerate}
/>
)}
contentContainerStyle={styles.listContent}
/>
{shouldShowTypingIndicator(messages, isStreaming) && <TypingIndicator />}
</>
);
}
const styles = StyleSheet.create({
listContent: {
paddingTop: 16,
paddingBottom: 24,
},
});
-7
View File
@@ -42,8 +42,6 @@ export function ModelSelector() {
style={[styles.headerButton, { backgroundColor: colors.cardBackground }]}
onPress={() => setModalVisible(true)}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={`Model: ${activeModelName}. Tap to change`}
>
<ConnectionBadge isConnected={isConnected} compact />
<Text style={[styles.headerText, { color: colors.text }]} numberOfLines={1}>
@@ -72,8 +70,6 @@ export function ModelSelector() {
onPress={handleRefresh}
style={[styles.refreshButton, { backgroundColor: colors.cardBackground }]}
disabled={isRefreshing}
accessibilityRole="button"
accessibilityLabel="Refresh models"
>
{isRefreshing ? (
<ActivityIndicator size="small" color={colors.textMuted} />
@@ -112,9 +108,6 @@ export function ModelSelector() {
isSelected && { borderColor: colors.tint }
]}
onPress={() => handleSelect(item.id)}
accessibilityRole="button"
accessibilityLabel={item.id}
accessibilityState={{ selected: isSelected }}
>
<View style={styles.itemContent}>
<Text style={[styles.itemName, { color: colors.text }]}>
-31
View File
@@ -1,31 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
export function OfflineBanner() {
const colors = useThemeColor();
return (
<View
accessibilityRole="alert"
style={[styles.banner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.bannerBg }]}
>
<Text style={[styles.bannerText, { color: colors.bannerText }]}>
⚠️ Server offline. Check Settings → Server Connection.
</Text>
</View>
);
}
const styles = StyleSheet.create({
banner: {
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: 1,
},
bannerText: {
fontSize: 13,
textAlign: 'center',
fontWeight: '500',
},
});
+127
View File
@@ -0,0 +1,127 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { BrainCircuit, ChevronDown, ChevronRight } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming
} from 'react-native-reanimated';
interface ThinkingBlockProps {
content: string;
isComplete: boolean;
isStreaming?: boolean;
}
export function ThinkingBlock({ content, isComplete, isStreaming }: ThinkingBlockProps) {
const colors = useThemeColor();
// Auto-expand while streaming/thinking, auto-collapse when complete
const [isExpanded, setIsExpanded] = useState(!isComplete);
// Auto collapse when it completes streaming
useEffect(() => {
if (isComplete && !isStreaming) {
setIsExpanded(false);
} else if (!isComplete && isStreaming) {
setIsExpanded(true);
}
}, [isComplete, isStreaming]);
// Pulse animation value
const pulseOpacity = useSharedValue(1);
useEffect(() => {
if (!isComplete && isStreaming) {
pulseOpacity.value = withRepeat(
withSequence(
withTiming(0.4, { duration: 800 }),
withTiming(1, { duration: 800 })
),
-1, // infinite
true // reverse
);
} else {
pulseOpacity.value = withTiming(1, { duration: 300 });
}
}, [isComplete, isStreaming, pulseOpacity]);
const animatedHeaderStyle = useAnimatedStyle(() => {
return {
opacity: pulseOpacity.value,
};
});
if (!content && isComplete) {
return null;
}
return (
<View style={[styles.container, { borderColor: colors.border }]}>
<TouchableOpacity
style={styles.header}
activeOpacity={0.7}
onPress={() => setIsExpanded(!isExpanded)}
>
<Animated.View style={[styles.titleRow, animatedHeaderStyle]}>
<BrainCircuit size={16} color={colors.textMuted} />
<Text style={[styles.titleText, { color: colors.textMuted }]}>
{!isComplete && isStreaming ? 'Pensando...' : 'Processo de Raciocínio'}
</Text>
</Animated.View>
{isExpanded ? (
<ChevronDown size={18} color={colors.textMuted} />
) : (
<ChevronRight size={18} color={colors.textMuted} />
)}
</TouchableOpacity>
{isExpanded && content.length > 0 && (
<View style={[styles.contentBlock, { borderTopColor: colors.border }]}>
<Text style={[styles.contentText, { color: colors.textMuted }]}>
{content}
</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
borderWidth: 1,
borderRadius: 8,
marginVertical: 8,
overflow: 'hidden',
width: '100%',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingVertical: 10,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
titleText: {
fontSize: 13,
fontWeight: '600',
},
contentBlock: {
paddingHorizontal: 12,
paddingVertical: 10,
borderTopWidth: 1,
},
contentText: {
fontSize: 14,
lineHeight: 20,
fontFamily: 'monospace',
},
});
-99
View File
@@ -1,99 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
type ThemeColors = ReturnType<typeof useThemeColor>;
/** Style map for the Markdown renderer, derived from the active theme colours. */
export function createMarkdownStyles(colors: ThemeColors) {
return {
body: {
fontSize: 15,
lineHeight: 22,
color: colors.messageAssistantText,
},
strong: {
fontWeight: '700',
color: colors.text,
},
em: {
fontStyle: 'italic',
},
code_inline: {
backgroundColor: colors.backgroundColor,
color: colors.text,
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 4,
fontSize: 13,
fontFamily: 'monospace',
},
fence: {
backgroundColor: colors.inputBg,
borderColor: colors.border,
borderWidth: 1,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
overflow: 'hidden',
},
code_block: {
backgroundColor: colors.inputBg,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: colors.border,
paddingLeft: 12,
marginVertical: 4,
color: colors.textMuted,
},
bullet_list: {
marginVertical: 4,
},
ordered_list: {
marginVertical: 4,
},
list_item: {
marginVertical: 2,
color: colors.messageAssistantText,
},
heading1: {
fontSize: 22,
fontWeight: '700',
marginVertical: 8,
color: colors.text,
},
heading2: {
fontSize: 19,
fontWeight: '700',
marginVertical: 6,
color: colors.text,
},
heading3: {
fontSize: 17,
fontWeight: '600',
marginVertical: 4,
color: colors.text,
},
link: {
color: '#3B82F6',
textDecorationLine: 'underline',
},
hr: {
backgroundColor: colors.border,
height: 1,
marginVertical: 12,
},
paragraph: {
marginVertical: 2,
},
// The Markdown renderer's style types do not accept every RN style key literal.
} as any;
}
+25
View File
@@ -0,0 +1,25 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}
+19
View File
@@ -0,0 +1,19 @@
import Animated from 'react-native-reanimated';
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
'50%': { transform: [{ rotate: '25deg' }] },
},
animationIterationCount: 4,
animationDuration: '300ms',
}}>
👋
</Animated.Text>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/components/themed-view';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useThemeColor } from '@/hooks/use-theme-color';
const HEADER_HEIGHT = 250;
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const backgroundColor = useThemeColor({}, 'background');
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollOffset(scrollRef);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
return (
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16}>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});
-38
View File
@@ -1,38 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { Info, MonitorCog } from 'lucide-react-native';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { SettingsSection } from './SettingsSection';
export function AboutCard() {
const colors = useThemeColor();
return (
<SettingsSection title="ABOUT">
<View style={styles.rowGroup}>
<Info size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.text }]}>LocalMind - Cross Platform Version</Text>
</View>
<View style={[styles.rowGroup, { marginTop: 8 }]}>
<MonitorCog size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.textMuted, fontSize: 13 }]}>
Connected via OpenAPI `/v1/chat/completions` specs
</Text>
</View>
</SettingsSection>
);
}
const styles = StyleSheet.create({
rowGroup: {
flexDirection: 'row',
alignItems: 'center',
},
infoIcon: {
marginRight: 10,
},
infoText: {
fontSize: 15,
lineHeight: 22,
},
});
-62
View File
@@ -1,62 +0,0 @@
import { useColorScheme } from '@/hooks/useColorScheme';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import { isDarkTheme } from '@/utils/theme';
import { Moon } from 'lucide-react-native';
import React from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import { SettingsSection } from './SettingsSection';
export function AppearanceCard() {
const colors = useThemeColor();
const systemScheme = useColorScheme();
const themePreference = useAppStore(state => state.theme);
const setTheme = useAppStore(state => state.setTheme);
const isDarkMode = isDarkTheme(themePreference, systemScheme);
const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark');
return (
<SettingsSection title="APPEARANCE">
<View style={styles.row}>
<View style={styles.rowIconWrap}>
<Moon size={20} color={colors.text} />
</View>
<View style={styles.rowText}>
<Text style={[styles.rowTitle, { color: colors.text }]}>Dark Mode</Text>
<Text style={[styles.rowSubtitle, { color: colors.textMuted }]}>Toggle sleek dark theme</Text>
</View>
<Switch
value={isDarkMode}
onValueChange={toggleTheme}
accessibilityLabel="Dark Mode"
trackColor={{ false: '#E2E8F0', true: colors.tint }}
thumbColor={'#FFFFFF'}
/>
</View>
</SettingsSection>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 4,
},
rowIconWrap: {
marginRight: 12,
},
rowText: {
flex: 1,
},
rowTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
rowSubtitle: {
fontSize: 14,
},
});
-44
View File
@@ -1,44 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react';
import { StyleSheet, Text, TextInput, TextInputProps, View } from 'react-native';
interface LabeledInputProps extends TextInputProps {
label: string;
}
export function LabeledInput({ label, style, ...inputProps }: LabeledInputProps) {
const colors = useThemeColor();
return (
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>{label}</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text },
style,
]}
placeholderTextColor={colors.textMuted}
{...inputProps}
/>
</View>
);
}
const styles = StyleSheet.create({
inputGroup: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
textInput: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
},
});
@@ -1,194 +0,0 @@
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor';
import * as api from '@/services/lmStudioApi';
import { useAppStore } from '@/store/useAppStore';
import { buildServerConfig, isConfigChanged } from '@/utils/serverConfig';
import { Network } from 'lucide-react-native';
import React, { useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { LabeledInput } from './LabeledInput';
import { SettingsSection } from './SettingsSection';
function TestResultBox({ ok }: { ok: boolean }) {
const colors = useThemeColor();
return (
<View style={[styles.resultBox, { backgroundColor: ok ? colors.successBg : colors.errorBg }]}>
<Text style={[styles.resultText, { color: ok ? colors.successText : colors.errorText }]}>
{ok
? 'Connection successful. API is ready.'
: 'Connection failed. Verify host and port.'}
</Text>
</View>
);
}
export function ServerConnectionCard() {
const colors = useThemeColor();
const serverConfig = useAppStore(state => state.serverConfig);
const setServerConfig = useAppStore(state => state.setServerConfig);
const isConnected = useAppStore(state => state.isConnected);
const setIsConnected = useAppStore(state => state.setIsConnected);
const { fetchModels } = useLMStudio();
const [host, setHost] = useState(serverConfig.host);
const [port, setPort] = useState(serverConfig.port.toString());
const [apiKey, setApiKey] = useState(serverConfig.apiKey ?? '');
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<boolean | null>(null);
const form = { host, port, apiKey };
const currentConfig = buildServerConfig(form);
const configChanged = isConfigChanged(form, serverConfig);
const handleSaveConfig = () => {
setServerConfig(currentConfig);
setTestResult(null);
};
const handleTestConnection = async () => {
handleSaveConfig();
setIsTesting(true);
setTestResult(null);
const ok = await api.testConnection(currentConfig);
setTestResult(ok);
setIsConnected(ok);
if (ok) {
await fetchModels();
}
setIsTesting(false);
};
return (
<SettingsSection title="SERVER CONNECTION">
<View style={styles.cardHeader}>
<View style={styles.cardHeaderTitle}>
<Network size={20} color={colors.text} style={styles.headerIcon} />
<Text style={[styles.cardTitle, { color: colors.text }]}>LM Studio API</Text>
</View>
<ConnectionBadge isConnected={isConnected} />
</View>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
<LabeledInput
label="Host IP / Address"
value={host}
onChangeText={setHost}
placeholder="localhost"
autoCapitalize="none"
autoCorrect={false}
/>
<LabeledInput
label="Port Number"
value={port}
onChangeText={setPort}
placeholder="1234"
keyboardType="numeric"
/>
<LabeledInput
label="API Key (optional)"
value={apiKey}
onChangeText={setApiKey}
placeholder="Leave empty for local"
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
/>
<View style={styles.buttonRow}>
{configChanged && (
<TouchableOpacity
style={[styles.saveButton, { borderColor: colors.border, backgroundColor: colors.cardBackground }]}
onPress={handleSaveConfig}
>
<Text style={[styles.saveButtonText, { color: colors.text }]}>Save Changes</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[
styles.testButton,
{ backgroundColor: colors.tint },
isTesting && styles.testButtonDisabled
]}
onPress={handleTestConnection}
disabled={isTesting}
>
{isTesting ? (
<ActivityIndicator size="small" color={colors.backgroundColor} />
) : (
<Text style={[styles.testButtonText, { color: colors.backgroundColor }]}>Test Connection</Text>
)}
</TouchableOpacity>
</View>
{testResult !== null && <TestResultBox ok={testResult} />}
</SettingsSection>
);
}
const styles = StyleSheet.create({
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
},
cardHeaderTitle: {
flexDirection: 'row',
alignItems: 'center',
},
headerIcon: {
marginRight: 8,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
},
divider: {
height: 1,
width: '100%',
marginBottom: 16,
},
buttonRow: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
saveButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
borderWidth: 1,
},
saveButtonText: {
fontSize: 15,
fontWeight: '600',
},
testButton: {
flex: 2,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
testButtonDisabled: {
opacity: 0.7,
},
testButtonText: {
fontSize: 15,
fontWeight: '600',
},
resultBox: {
marginTop: 16,
borderRadius: 12,
padding: 14,
},
resultText: {
fontSize: 14,
textAlign: 'center',
fontWeight: '500',
},
});
-45
View File
@@ -1,45 +0,0 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react';
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
interface SettingsSectionProps {
title: string;
children: React.ReactNode;
}
export function SettingsSection({ title, children }: SettingsSectionProps) {
const { width } = useWindowDimensions();
const colors = useThemeColor();
const isLargeScreen = width > 768;
return (
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>{title}</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
{children}
</View>
</View>
);
}
const styles = StyleSheet.create({
section: {
marginBottom: 24,
},
desktopSection: {
width: '100%',
maxWidth: 600,
},
sectionLabel: {
fontSize: 13,
fontWeight: '600',
marginLeft: 16,
marginBottom: 8,
letterSpacing: 0.5,
},
card: {
borderRadius: 16,
padding: 16,
borderWidth: 1,
},
});
+60
View File
@@ -0,0 +1,60 @@
import { StyleSheet, Text, type TextProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});
+14
View File
@@ -0,0 +1,14 @@
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}
+118
View File
@@ -0,0 +1,118 @@
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import React from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, TextStyle, ViewStyle } from 'react-native';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
icon?: React.ReactNode;
}
export function Button({
title,
onPress,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
style,
textStyle,
icon
}: ButtonProps) {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'dark'];
const getBackgroundColor = () => {
if (disabled) return theme.surfaceHighlight;
switch (variant) {
case 'primary': return theme.primary;
case 'secondary': return theme.surfaceHighlight;
case 'outline': return 'transparent';
case 'ghost': return 'transparent';
default: return theme.primary;
}
};
const getTextColor = () => {
if (disabled) return theme.textSecondary;
switch (variant) {
case 'primary': return theme.background; // Black text on white bg
case 'secondary': return theme.text;
case 'outline': return theme.text;
case 'ghost': return theme.textSecondary;
default: return theme.background;
}
};
const getBorder = () => {
if (variant === 'outline') return { borderWidth: 1, borderColor: theme.border };
return {};
};
const getPadding = () => {
switch (size) {
case 'sm': return { paddingVertical: 8, paddingHorizontal: 12 };
case 'lg': return { paddingVertical: 16, paddingHorizontal: 24 };
default: return { paddingVertical: 12, paddingHorizontal: 20 };
}
};
const getFontSize = () => {
switch (size) {
case 'sm': return 13;
case 'lg': return 17;
default: return 15;
}
};
return (
<Pressable
onPress={onPress}
disabled={disabled || loading}
style={({ pressed }) => [
styles.button,
{ backgroundColor: getBackgroundColor() },
getBorder(),
getPadding(),
{ opacity: pressed ? 0.8 : 1 },
style
]}
>
{loading ? (
<ActivityIndicator color={getTextColor()} />
) : (
<>
{icon}
<Text style={[
styles.text,
{ color: getTextColor(), fontSize: getFontSize() },
icon ? { marginLeft: 8 } : {},
textStyle
]}>
{title}
</Text>
</>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
text: {
fontWeight: '600',
letterSpacing: 0.5,
},
});
+36
View File
@@ -0,0 +1,36 @@
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { StyleSheet, View, ViewStyle } from 'react-native';
interface GlassCardProps {
children: React.ReactNode;
style?: ViewStyle;
variant?: 'default' | 'highlight';
}
export function GlassCard({ children, style, variant = 'default' }: GlassCardProps) {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'dark'];
return (
<View style={[
styles.card,
{
backgroundColor: variant === 'default' ? theme.surface : theme.surfaceHighlight,
borderColor: theme.border,
},
style
]}>
{children}
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
overflow: 'hidden',
},
});
+66
View File
@@ -0,0 +1,66 @@
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { StatusBar } from 'expo-status-bar';
import { ScrollView, StyleSheet, View, ViewStyle } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface ScreenWrapperProps {
children: React.ReactNode;
style?: ViewStyle;
contentContainerStyle?: ViewStyle;
scrollable?: boolean;
}
export function ScreenWrapper({
children,
style,
contentContainerStyle,
scrollable = false
}: ScreenWrapperProps) {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'dark'];
const insets = useSafeAreaInsets();
const containerStyle = [
styles.container,
{
backgroundColor: theme.background,
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
},
style
];
if (scrollable) {
return (
<View style={{ flex: 1, backgroundColor: theme.background }}>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<ScrollView
style={containerStyle}
contentContainerStyle={[
{ paddingBottom: insets.bottom + 20 },
contentContainerStyle
]}
>
{children}
</ScrollView>
</View>
);
}
return (
<View style={containerStyle}>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<View style={[{ flex: 1, paddingBottom: insets.bottom }, contentContainerStyle]}>
{children}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
+45
View File
@@ -0,0 +1,45 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});
+32
View File
@@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}
+41
View File
@@ -0,0 +1,41 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}
+33
View File
@@ -0,0 +1,33 @@
export type AIModel = {
id: string;
name: string;
parameters: string;
huggingFaceUrl: string;
description: string;
};
export const QWEN_MODELS: AIModel[] = [
{
id: "qwen1.5-0.5b-chat",
name: "Qwen 1.5 Chat",
parameters: "0.5B",
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-0.5B-Chat",
description: "Ultra-lightweight model, extremely fast for basic tasks.",
},
{
id: "qwen1.5-1.8b-chat",
name: "Qwen 1.5 Chat",
parameters: "1.8B",
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-1.8B-Chat",
description: "Good balance of speed and reasoning for mobile devices.",
},
{
id: "qwen1.5-4b-chat",
name: "Qwen 1.5 Chat",
parameters: "4B",
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-4B-Chat",
description: "Highest local quality, requires more RAM and processing power.",
}
];
export const DEFAULT_MODEL = QWEN_MODELS[0];
+87
View File
@@ -0,0 +1,87 @@
/**
* ManusAI Theme - Monochromatic/Minimalist
*
* Design System:
* - Primary: White (#ffffff)
* - Background: Deep Matte Black (#09090b)
* - Surface: Zinc-900 (#18181b)
* - Border: Zinc-700 (#3f3f46)
* - Text: Zinc-50 (#fafafa)
*/
import { Platform } from 'react-native';
const ManusColors = {
// Base
black: '#09090b',
zinc900: '#18181b',
zinc800: '#27272a',
zinc700: '#3f3f46',
zinc400: '#a1a1aa',
zinc50: '#fafafa',
white: '#ffffff',
// Semantic
background: '#09090b',
surface: '#18181b',
surfaceHighlight: '#27272a',
primary: '#ffffff',
border: '#3f3f46',
text: '#fafafa',
textSecondary: '#a1a1aa',
error: '#ef4444',
};
export const Colors = {
light: {
text: ManusColors.black,
background: ManusColors.white,
tint: ManusColors.black,
icon: ManusColors.zinc700,
tabIconDefault: ManusColors.zinc400,
tabIconSelected: ManusColors.black,
// Manus Specific
surface: '#f4f4f5', // Zinc-100
surfaceHighlight: '#e4e4e7', // Zinc-200
border: '#d4d4d8', // Zinc-300
textSecondary: ManusColors.zinc700,
primary: ManusColors.black,
},
dark: {
text: ManusColors.text,
background: ManusColors.background,
tint: ManusColors.primary,
icon: ManusColors.zinc400,
tabIconDefault: ManusColors.zinc400,
tabIconSelected: ManusColors.primary,
// Manus Specific
surface: ManusColors.surface,
surfaceHighlight: ManusColors.surfaceHighlight,
border: ManusColors.border,
textSecondary: ManusColors.textSecondary,
primary: ManusColors.primary,
},
// Unified access for components that ignore system theme
manus: ManusColors,
};
export const Fonts = Platform.select({
ios: {
sans: 'system-ui',
serif: 'ui-serif',
rounded: 'ui-rounded',
mono: 'ui-monospace',
},
android: {
sans: 'sans-serif',
serif: 'serif',
rounded: 'sans-serif-medium',
mono: 'monospace',
},
default: {
sans: 'system-ui',
serif: 'serif',
rounded: 'system-ui',
mono: 'monospace',
},
});
+1
View File
@@ -0,0 +1 @@
export { useColorScheme } from 'react-native';
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
-23
View File
@@ -1,23 +0,0 @@
import { useEffect } from 'react';
import { useAppStore } from '../store/useAppStore';
import { useLMStudio } from './useLMStudio';
/**
* Checks the server once when the app opens, after the saved settings have been
* restored, so the connection badge and model list are not stale until the user
* visits Settings.
*/
export function useAutoConnect() {
const { fetchModels } = useLMStudio();
useEffect(() => {
const { persist } = useAppStore;
if (persist.hasHydrated()) {
void fetchModels();
return;
}
return persist.onFinishHydration(() => void fetchModels());
// Mount only: later checks are triggered by the user (model selector, Settings).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
+199 -55
View File
@@ -2,16 +2,8 @@ import { useCallback, useRef } from 'react';
import { SYSTEM_PROMPT } from '../constants/SystemPrompt';
import * as api from '../services/lmStudioApi';
import { ChatMessage as ApiMessage } from '../services/types';
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 })),
];
}
import { useAppStore } from '../store/useAppStore';
import { WebSearchLogger } from '../services/webSearchLogger';
export function useLMStudio() {
const serverConfig = useAppStore((s) => s.serverConfig);
@@ -53,16 +45,196 @@ export function useLMStudio() {
}
}, [activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
// Adds an empty assistant message and streams the model's answer into it.
const streamAnswer = useCallback((modelId: string, apiMessages: ApiMessage[]) => {
const sendMessage = useCallback(async (text: string) => {
if (isStreaming) return;
const pipelineStart = Date.now();
const store = useAppStore.getState();
const isWebSearchEnabled = store.isWebSearchEnabled;
addMessage({ role: 'user', content: text });
const modelId = store.activeModelId;
if (!modelId) {
addMessage({
role: 'assistant',
content: '⚠️ No model selected. Please select a model first.',
});
return;
}
const assistantId = addMessage({ role: 'assistant', content: '' });
setIsStreaming(true);
const finish = () => {
try {
let searchContext = '';
let searchSources: Array<{ title: string; url: string }> = [];
if (isWebSearchEnabled) {
// ── Step 1: Build search service URL ─────────────────────────────
// ServerConfig has { host, port } — construct the search service URL
// from the same host, but on port 3030.
const rawHost = store.serverConfig.host.replace(/\/+$/, '');
const hostWithProtocol = rawHost.startsWith('http') ? rawHost : `http://${rawHost}`;
const searchEndpoint = `${hostWithProtocol}:3030/search-and-extract`;
WebSearchLogger.pipelineStart(text, searchEndpoint);
WebSearchLogger.endpointResolved(
store.serverConfig.host,
store.serverConfig.port,
searchEndpoint,
);
appendToMessage(assistantId, '<think>Searching the web...</think>\n');
try {
const maxContextLength = 10000;
// 60s timeout: SearXNG + 3 páginas em paralelo (max ~8s cada) + parsing
// Sequential era ~24s worst case; paralelo cabe em ~10-15s. 60s é margem segura.
const SEARCH_TIMEOUT_MS = 60000;
const searchController = new AbortController();
const searchTimeout = setTimeout(() => {
searchController.abort();
}, SEARCH_TIMEOUT_MS);
WebSearchLogger.requestSent(text, maxContextLength, SEARCH_TIMEOUT_MS);
const searchRes = await fetch(searchEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: text,
maxContextLength,
}),
signal: searchController.signal,
});
clearTimeout(searchTimeout);
if (searchRes.ok) {
const searchData = await searchRes.json();
searchContext = searchData.context ?? '';
searchSources = searchData.sources ?? [];
WebSearchLogger.contextReceived(
searchContext,
searchSources,
searchData.meta,
);
} else {
WebSearchLogger.requestFailed(searchRes.status, searchRes.statusText);
}
} catch (error) {
const isTimeout = error instanceof Error && error.name === 'AbortError';
WebSearchLogger.serviceUnreachable(error, isTimeout);
}
// Clear the temporary "searching..." thought bubble
store.updateMessageContent(assistantId, '');
}
// ── Step 2: Build API messages ──────────────────────────────────────
const currentMessages = useAppStore.getState().messages;
// Exclude the empty assistant placeholder from the history
const historyToSend = currentMessages.slice(0, currentMessages.length - 1);
// Warn explicitly when web search was enabled but returned no context
if (isWebSearchEnabled && !searchContext) {
console.warn(
'[WebSearch/Fallback] ⚠️ Web search was enabled but returned NO context. ' +
'Proceeding with LLM response without web data. ' +
'Check if local-search-service is running at the correct address.',
);
}
const systemContent = SYSTEM_PROMPT + (searchContext ? `\n\n${searchContext}` : '');
const apiMessages: ApiMessage[] = [
{ role: 'system', content: systemContent },
...historyToSend.map((m) => ({
role: m.role,
content: m.content,
})),
];
// ── Step 3: Context Window Analysis ────────────────────────────────
if (isWebSearchEnabled) {
const historyChars = historyToSend.reduce((sum, m) => sum + m.content.length, 0);
WebSearchLogger.contextAnalysis(
systemContent.length,
historyChars,
searchContext.length,
);
}
// ── Step 4: Stream LLM response ────────────────────────────────────
WebSearchLogger.llmRequestStart(modelId, apiMessages.length);
let chunkCount = 0;
abortRef.current = api.chatCompletionStream(
serverConfig,
modelId,
apiMessages,
(chunk) => {
chunkCount++;
appendToMessage(assistantId, chunk);
},
() => {
WebSearchLogger.llmStreamComplete(assistantId, chunkCount);
WebSearchLogger.pipelineComplete(text, Date.now() - pipelineStart, isWebSearchEnabled);
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
},
(error) => {
WebSearchLogger.llmError(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);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
};
}
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
const regenerateResponse = useCallback(async () => {
if (isStreaming) return;
const msgs = useAppStore.getState().messages;
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];
const store = useAppStore.getState();
store.deleteMessagesAfter(lastUserMsg.id);
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 {
abortRef.current = api.chatCompletionStream(
@@ -70,58 +242,30 @@ export function useLMStudio() {
modelId,
apiMessages,
(chunk) => appendToMessage(assistantId, chunk),
finish,
() => {
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
},
(error) => {
appendToMessage(assistantId, `
⚠️ Error: ${error.message}`);
finish();
appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
},
);
} catch (err) {
appendToMessage(assistantId, `⚠️ Connection failed: ${(err as Error).message}`);
finish();
setIsStreaming(false);
useAppStore.getState().saveCurrentChat();
}
}, [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]);
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
const stopStreaming = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
setIsStreaming(false);
useAppStore.getState().saveCurrentChat();
}
}, [setIsStreaming]);
-13
View File
@@ -1,13 +0,0 @@
module.exports = {
preset: 'jest-expo',
setupFiles: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
// jest-expo's default pattern plus the ESM-only markdown renderer.
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@ronradtke/.*)',
],
testMatch: ['<rootDir>/__tests__/**/*.test.{ts,tsx}'],
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/.agent/'],
};
-3
View File
@@ -1,3 +0,0 @@
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);
+83
View File
@@ -0,0 +1,83 @@
# Local AI Database Strategy (LanceDB + SQLite)
## Overview
A 100% offline-first local database architecture for storing AI memories in a React Native (Expo) application. The solution splits the data into two highly optimized engines:
1. **`expo-sqlite`**: Manages structured text, sessions, dates, and relationships.
2. **`LanceDB`**: Manages the high-dimensional vector embeddings, pointed by SQLite relationship IDs.
> [!WARNING] **Architectural Risk (LanceDB on Mobile)**
> LanceDB relies on robust Apache Arrow and native dependencies. Running it completely on-device in a React Native/Expo environment needs careful validation (e.g., verifying polyfills or WebAssembly compatibility), since most vector DBs are backend/Node.js native.
## Project Type
**MOBILE** (React Native / Expo)
## Success Criteria
- [ ] Both databases can correctly initialize on iOS and Android simulators/devices.
- [ ] A memory string can be successfully indexed (stored in SQLite + embedding stored in LanceDB).
- [ ] A semantic search query returns the correct related memories locally.
- [ ] Application does not crash due to native library incompatibilities.
## Tech Stack
- **`expo-sqlite`**: The official, lightweight SQLite solution for the Expo SDK, providing fully synchronous mapping properties.
- **`@lancedb/lancedb`** (or equivalent mobile-compatible API): Vector store for fast embedding similarity searches locally.
- **`zustand`** (Already installed): State management for caching the queries/memories if needed in the UI.
## File Structure
```
app/
...
services/
db/
sqliteClient.ts # SQLite connection and raw queries
lanceDbClient.ts # LanceDB vector search and connection
memoryService.ts # Unifies both DBs to create the "AI Memories" logic
hooks/
useAiMemories.ts # React Hook to expose database queries
types/
database.d.ts # TS interfaces for Tables and Documents
```
## Task Breakdown
### Task 1: Environment Research & Setup
- **Agent**: `mobile-developer`
- **Skill**: `mobile-design`
- **INPUT**: Current `package.json`
- **OUTPUT**: Installation of `expo-sqlite` and LanceDB dependencies. Verified bare React Native compatibility for LanceDB.
- **VERIFY**: Running a successful `npx expo start` and `npx expo run:android` without build failures.
### Task 2: Database Schema & Layer
- **Agent**: `database-architect` (simulated by `mobile-developer`)
- **Skill**: `database-design`
- **INPUT**: Dependencies installed.
- **OUTPUT**:
- `sqliteClient.ts`: `Sessions`, `Messages`, `Metadata` tables.
- `lanceDbClient.ts`: LanceDB table initialization with vector dimensions (e.g. 384 or 1536).
- **VERIFY**: Launching the app correctly fires the initialization queries (logs "DB Connected").
### Task 3: Unifying Memory Service
- **Agent**: `mobile-developer`
- **Skill**: `clean-code`
- **INPUT**: Initialized Database clients.
- **OUTPUT**: `memoryService.ts` containing functions like `saveMemory(text, embedding)` and `searchSimilar(embedding, limit)`.
- **VERIFY**: Hardcoding a mock vector search and getting the SQLite structured data back.
### Task 4: Hook UI Integration
- **Agent**: `mobile-developer`
- **Skill**: `mobile-design`
- **INPUT**: `memoryService.ts` logic.
- **OUTPUT**: `useAiMemories.ts` hook ready to be plugged into chat components.
- **VERIFY**: Test components renders data from the DB layer.
---
## ✅ PHASE X: Verification Checklist
*To be completed after Phase 4.*
- **Linting:** ⬜ Pass (`npm run lint` & `tsc`)
- **Build (Android):** ⬜ Pass (`npx expo run:android`)
- **Build (iOS):** ⬜ Pass (`npx expo run:ios`)
- **Feature Check:** ⬜ Vector search and relation query works offline.
- **Color/UI Rules:** ⬜ UI follows standard themes (Not applicable for DB layer but required by protocol).
- **Date:** TBD
+32
View File
@@ -0,0 +1,32 @@
# Local Search Service
This is the background Web Search extraction service for the **LocalAiApp**. It acts as a lightweight proxy and DOM parser that connects your mobile app (React Native) to SearXNG, ensuring the Local AI context is pristine Markdown.
## How it Works
1. Receives a search query from the LocalAiApp via `POST /search-and-extract`.
2. Asks your local `SearXNG` instance for the top results.
3. Fetches the HTML of the resulting pages.
4. Uses `linkedom` and `Readability` to strip out all menus, footers, scripts, and clutter.
5. Converts the clean article payload into `Markdown` using `node-html-markdown`.
6. Returns the pure Markdown text to the LocalAiApp to inject into the AI's prompt.
## Setup
### Requirements
- Node.js v18+
- A running instance of [SearXNG](https://github.com/searxng/searxng) (running locally at `http://127.0.0.1:8888` by default).
### Running
1. Install dependencies:
```bash
npm install
```
2. Start the service (runs on port `3030`):
```bash
npm start
```
## Integrations
Inside the main `LocalAiApp`, the chat input now has a Web Search toggle (the Globe icon). When activated, every prompt sent will first trigger the search service. The service fetches context online and feeds the result as an invisible pre-system-prompt to LM Studio!
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "local-search-service",
"version": "1.0.0",
"description": "Helper service for LocalAiApp to fetch and extract web context",
"scripts": {
"start": "ts-node server.ts",
"dev": "ts-node server.ts"
},
"dependencies": {
"@mozilla/readability": "^0.5.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"linkedom": "^0.18.5",
"node-html-markdown": "^1.3.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/mozilla-readability": "^0.2.1",
"@types/node": "^22.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.6.2"
}
}
+435
View File
@@ -0,0 +1,435 @@
import express, { Request, Response } from 'express';
import cors from 'cors';
import { parseHTML } from 'linkedom';
import { Readability } from '@mozilla/readability';
import { NodeHtmlMarkdown } from 'node-html-markdown';
const app = express();
const PORT = process.env.PORT || 3030;
app.use(cors());
app.use(express.json());
// ─── Logger ────────────────────────────────────────────────────────────────────
type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG';
function log(level: LogLevel, phase: string, message: string, meta?: Record<string, any>) {
const ts = new Date().toISOString();
const metaStr = meta ? ` | ${JSON.stringify(meta)}` : '';
const icons: Record<LogLevel, string> = { INFO: 'ℹ️', WARN: '⚠️', ERROR: '❌', DEBUG: '🔍' };
console.log(`[${ts}] ${icons[level]} [${phase}] ${message}${metaStr}`);
}
function logSection(title: string) {
const border = '═'.repeat(60);
console.log(`\n${border}`);
console.log(` ${title}`);
console.log(`${border}`);
}
// ─── Helpers ───────────────────────────────────────────────────────────────────
/** Rough token count estimate (~4 chars per token) */
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
/**
* Context Analysis — scores each extracted source by:
* - content length (longer = more info)
* - keyword overlap with the query
* - penalizes sources with very short content (likely failed extractions)
*/
function scoreSource(
source: { title: string; url: string; content: string },
query: string,
): number {
const queryWords = query
.toLowerCase()
.split(/\s+/)
.filter((w) => w.length > 3);
const contentLower = source.content.toLowerCase();
const titleLower = source.title.toLowerCase();
// Keyword relevance: count query words present in content/title
const keywordMatches = queryWords.filter(
(w) => contentLower.includes(w) || titleLower.includes(w),
).length;
const keywordScore = queryWords.length > 0 ? keywordMatches / queryWords.length : 0;
// Length score (0–1): prefer 500–3000 char range
const len = source.content.length;
const lengthScore = len < 100 ? 0.1 : len > 500 ? Math.min(1, len / 3000) : 0.5;
// Penalize if content is just a fallback snippet (very short)
const snippetPenalty = len < 200 ? 0.4 : 1.0;
const total = (keywordScore * 0.6 + lengthScore * 0.4) * snippetPenalty;
log('DEBUG', 'ContextAnalysis', `Scored source: "${source.title}"`, {
url: source.url,
contentLength: len,
keywordMatches,
keywordScore: keywordScore.toFixed(2),
lengthScore: lengthScore.toFixed(2),
snippetPenalty,
totalScore: total.toFixed(3),
});
return total;
}
/**
* Intelligent Context Pruning:
* 1. Sort sources by relevance score (descending).
* 2. Include full sources one by one until the budget is reached.
* 3. If a single source already exceeds budget, truncate it at a sentence boundary.
* This avoids cutting in the middle of a word / sentence.
*/
function buildAndPruneContext(
sources: Array<{ title: string; url: string; content: string }>,
query: string,
maxChars: number,
): { markdown: string; pruned: boolean; keptSources: number; droppedSources: number } {
logSection('🧠 Context Analysis & Pruning');
// Score and rank sources
const scored = sources
.map((src) => ({ ...src, score: scoreSource(src, query) }))
.sort((a, b) => b.score - a.score);
log('INFO', 'ContextPruning', `Ranked ${scored.length} sources by relevance`, {
ranking: scored.map((s, i) => ({
rank: i + 1,
title: s.title.substring(0, 50),
score: s.score.toFixed(3),
chars: s.content.length,
estimatedTokens: estimateTokens(s.content),
})),
});
const header = `# Web Search Context for: "${query}"\n\n`;
let markdown = header;
let budgetUsed = header.length;
let keptSources = 0;
let droppedSources = 0;
let pruned = false;
for (const src of scored) {
const entry =
`## Source ${keptSources + 1}: [${src.title}](${src.url})\n\n` +
`${src.content}\n\n` +
`---\n\n`;
if (budgetUsed + entry.length <= maxChars) {
markdown += entry;
budgetUsed += entry.length;
keptSources++;
log('INFO', 'ContextPruning', `✅ Included source (budget OK)`, {
rank: keptSources,
title: src.title.substring(0, 50),
entryChars: entry.length,
budgetUsed,
budgetRemaining: maxChars - budgetUsed,
});
} else {
const remaining = maxChars - budgetUsed;
if (keptSources === 0 && remaining > 200) {
// First source is already too large — truncate at last sentence boundary
const entryHeader =
`## Source 1: [${src.title}](${src.url})\n\n`;
const contentBudget = remaining - entryHeader.length - 60; // room for suffix
let truncatedContent = src.content.substring(0, contentBudget);
// Find last sentence boundary (. ! ?)
const lastSentence = Math.max(
truncatedContent.lastIndexOf('. '),
truncatedContent.lastIndexOf('! '),
truncatedContent.lastIndexOf('? '),
truncatedContent.lastIndexOf('\n'),
);
if (lastSentence > contentBudget * 0.5) {
truncatedContent = truncatedContent.substring(0, lastSentence + 1);
}
markdown +=
entryHeader +
truncatedContent +
'\n\n...[CONTENT TRUNCATED — token budget exceeded]...\n\n---\n\n';
keptSources++;
pruned = true;
droppedSources = scored.length - keptSources;
log('WARN', 'ContextPruning', `✂️ Truncated oversized first source at sentence boundary`, {
original: src.content.length,
truncated: truncatedContent.length,
budgetRemaining: remaining,
});
break;
}
// Drop this source entirely — not enough budget
droppedSources++;
pruned = true;
log('WARN', 'ContextPruning', `🗑️ Dropped source (insufficient budget)`, {
title: src.title.substring(0, 50),
entryChars: entry.length,
budgetRemaining: remaining,
score: src.score.toFixed(3),
});
}
}
return { markdown, pruned, keptSources, droppedSources };
}
// ─── Main Search Endpoint ──────────────────────────────────────────────────────
app.post('/search-and-extract', async (req: Request, res: Response): Promise<any> => {
const requestId = `req_${Date.now().toString(36)}`;
const startTime = Date.now();
try {
const {
query,
searxngUrl = 'http://127.0.0.1:8888',
topK = 3,
maxContextLength = 8000,
} = req.body;
logSection(`🌍 Web Search Pipeline — [${requestId}]`);
log('INFO', 'Request', `Received search request`, {
requestId,
query,
searxngUrl,
topK,
maxContextLength,
estimatedTokenBudget: estimateTokens(String(maxContextLength)),
});
if (!query) {
log('WARN', 'Validation', `Rejected: query is missing`, { requestId });
return res.status(400).json({ error: 'Query is missing' });
}
// ── Step 1: SearXNG Search ─────────────────────────────────────────────
log('INFO', 'SearXNG', `Querying SearXNG...`, { url: searxngUrl, query });
const searxStart = Date.now();
const searchRes = await fetch(
`${searxngUrl}/search?q=${encodeURIComponent(query)}&format=json`,
);
const searxLatency = Date.now() - searxStart;
if (!searchRes.ok) {
log('ERROR', 'SearXNG', `Search failed`, {
status: searchRes.status,
latencyMs: searxLatency,
});
throw new Error(
`SearXNG search failed. Status: ${searchRes.status}. ` +
`Make sure SearXNG is running and 'json' format is enabled in settings.yml.`,
);
}
const searchData = await searchRes.json();
const results = searchData.results || [];
const topResults = results.slice(0, topK).filter((r: any) => r.url);
log('INFO', 'SearXNG', `Search complete`, {
requestId,
latencyMs: searxLatency,
totalResultsFound: results.length,
processingTopK: topResults.length,
urls: topResults.map((r: any) => r.url),
});
// ── Step 2: Page Extraction (parallel) ────────────────────────────────
// All pages are fetched concurrently with Promise.allSettled so a single
// slow or failing page never blocks the others.
logSection('📄 Page Extraction (parallel)');
log('INFO', 'Extractor', `Starting parallel fetch for ${topResults.length} pages`, {
urls: topResults.map((r: any) => r.url),
});
async function extractPage(result: any): Promise<{
ctx: { title: string; url: string; content: string } | null;
stat: Record<string, any>;
}> {
const pageStart = Date.now();
log('INFO', 'Extractor', `Fetching page...`, { url: result.url });
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const pageRes = await fetch(result.url, {
signal: controller.signal,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; LocalSearchBot/1.0)' },
});
clearTimeout(timeoutId);
const pageLatency = Date.now() - pageStart;
if (!pageRes.ok) {
log('WARN', 'Extractor', `Skipping page — bad HTTP status`, {
url: result.url,
status: pageRes.status,
latencyMs: pageLatency,
});
return { ctx: null, stat: { url: result.url, status: 'skipped', reason: `HTTP ${pageRes.status}`, latencyMs: pageLatency } };
}
const html = await pageRes.text();
const htmlSize = html.length;
// ── Readability Extraction ──────────────────────────────────────
const { document } = parseHTML(html);
const reader = new Readability(document as any);
const article = reader.parse();
if (!article || !article.content) {
log('WARN', 'Extractor', `Readability failed — using snippet fallback`, {
url: result.url,
htmlSizeChars: htmlSize,
latencyMs: pageLatency,
});
const fallbackContent = result.content || 'Content could not be parsed.';
return {
ctx: { title: result.title || 'Unknown', url: result.url, content: fallbackContent },
stat: { url: result.url, status: 'fallback_snippet', contentChars: fallbackContent.length, latencyMs: pageLatency },
};
}
// ── HTML → Markdown ─────────────────────────────────────────────
const markdown = NodeHtmlMarkdown.translate(article.content);
const pageLatencyFinal = Date.now() - pageStart;
log('INFO', 'Extractor', `✅ Extracted successfully`, {
url: result.url,
title: (article.title || result.title).substring(0, 60),
htmlSizeChars: htmlSize,
markdownChars: markdown.length,
estimatedTokens: estimateTokens(markdown),
latencyMs: pageLatencyFinal,
});
return {
ctx: { title: article.title || result.title, url: result.url, content: markdown },
stat: { url: result.url, status: 'success', htmlChars: htmlSize, markdownChars: markdown.length, estimatedTokens: estimateTokens(markdown), latencyMs: pageLatencyFinal },
};
} catch (err: any) {
const pageLatency = Date.now() - pageStart;
const isTimeout = err.name === 'AbortError';
log('ERROR', 'Extractor', `Failed to process page`, {
url: result.url,
error: err.message,
isTimeout,
latencyMs: pageLatency,
});
return { ctx: null, stat: { url: result.url, status: isTimeout ? 'timeout' : 'error', error: err.message, latencyMs: pageLatency } };
}
}
// Run all extractions in parallel
const parallelStart = Date.now();
const settled = await Promise.allSettled(topResults.map((r: any) => extractPage(r)));
const parallelLatency = Date.now() - parallelStart;
const extractedContexts: Array<{ title: string; url: string; content: string }> = [];
const extractionStats: Array<Record<string, any>> = [];
for (const result of settled) {
if (result.status === 'fulfilled') {
if (result.value.ctx) extractedContexts.push(result.value.ctx);
extractionStats.push(result.value.stat);
} else {
// Promise itself rejected (shouldn't happen since extractPage catches internally)
extractionStats.push({ status: 'unexpected_rejection', error: result.reason?.message });
}
}
log('INFO', 'Extractor', `Extraction phase complete (parallel)`, {
requestId,
parallelLatencyMs: parallelLatency,
attempted: topResults.length,
succeeded: extractedContexts.length,
failed: topResults.length - extractedContexts.length,
extractionStats,
});
// ── Step 3: Context Analysis + Intelligent Pruning ────────────────────
const totalRawChars = extractedContexts.reduce((sum, c) => sum + c.content.length, 0);
const totalRawTokens = estimateTokens(totalRawChars.toString()) * 4; // rough
log('INFO', 'ContextAnalysis', `Pre-pruning context stats`, {
requestId,
sources: extractedContexts.length,
totalRawChars,
estimatedRawTokens: estimateTokens(extractedContexts.reduce((s, c) => s + c.content, '')),
maxContextLength,
maxContextTokensEstimate: estimateTokens('x'.repeat(maxContextLength)),
pruningNeeded: totalRawChars > maxContextLength,
});
const { markdown: combinedMarkdown, pruned, keptSources, droppedSources } =
buildAndPruneContext(extractedContexts, query, maxContextLength);
// ── Step 4: Final Stats & Response ────────────────────────────────────
const totalLatency = Date.now() - startTime;
const finalChars = combinedMarkdown.length;
const finalTokens = estimateTokens(combinedMarkdown);
logSection('📤 Pipeline Complete');
log('INFO', 'Response', `Sending context to LocalAiApp`, {
requestId,
query,
totalPipelineLatencyMs: totalLatency,
searxLatencyMs: searxLatency,
finalContextChars: finalChars,
finalContextEstimatedTokens: finalTokens,
sourcesKept: keptSources,
sourcesDropped: droppedSources,
pruned,
sourceTitles: extractedContexts.map((c) => c.title.substring(0, 50)),
});
console.log(`${'═'.repeat(60)}\n`);
return res.json({
success: true,
context: combinedMarkdown,
sources: extractedContexts.map((c) => ({ title: c.title, url: c.url })),
meta: {
requestId,
totalLatencyMs: totalLatency,
finalContextChars: finalChars,
finalContextEstimatedTokens: finalTokens,
sourcesKept: keptSources,
sourcesDropped: droppedSources,
pruned,
},
});
} catch (error: any) {
const totalLatency = Date.now() - startTime;
log('ERROR', 'Fatal', `Pipeline failed`, {
requestId,
error: error.message,
stack: error.stack?.split('\n').slice(0, 5),
totalLatencyMs: totalLatency,
});
return res.status(500).json({ error: error.message, requestId });
}
});
app.listen(Number(PORT), '0.0.0.0', () => {
log('INFO', 'Server', `Local Search Service started`, {
port: PORT,
address: `http://0.0.0.0:${PORT}`,
endpoints: ['/search-and-extract'],
});
});
-15753
View File
File diff suppressed because it is too large. Load diff
+14 -17
View File
@@ -4,15 +4,14 @@
"version": "1.0.0",
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"test": "jest",
"build": "expo export --platform web"
"lint": "expo lint"
},
"dependencies": {
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/runtime": "^7.28.6",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -20,25 +19,29 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@ronradtke/react-native-markdown-display": "^8.1.0",
"expo": "~54.0.37",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.14",
"expo-font": "~14.0.12",
"expo": "~54.0.31",
"expo-clipboard": "^55.0.8",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.10",
"expo-haptics": "~15.0.8",
"expo-linking": "~8.0.12",
"expo-router": "~6.0.24",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.21",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"immer": "^11.1.4",
"lucide-react-native": "^0.576.0",
"react": "19.1.0",
"react": "^19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-sse": "^1.2.1",
"react-native-svg": "15.12.1",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
@@ -46,15 +49,9 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^29.5.14",
"@types/react": "~19.1.0",
"babel-preset-expo": "~54.0.12",
"eslint": "^9.25.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"
},
"private": true
+290 -1
View File
@@ -8,9 +8,18 @@ importers:
.:
dependencies:
'@babel/plugin-transform-runtime':
specifier: ^7.29.0
version: 7.29.0(@babel/[email protected])
'@babel/runtime':
specifier: ^7.28.6
version: 7.28.6
'@expo/vector-icons':
specifier: ^15.0.3
version: 15.1.1([email protected]([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]))([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
'@react-native-async-storage/async-storage':
specifier: 2.2.0
version: 2.2.0([email protected](@babel/[email protected])(@types/[email protected])([email protected]))
'@react-navigation/bottom-tabs':
specifier: ^7.4.0
version: 7.15.5(@react-navigation/[email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]))([email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]))([email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]))([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
@@ -20,9 +29,15 @@ importers:
'@react-navigation/native':
specifier: ^7.1.8
version: 7.1.33([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
'@ronradtke/react-native-markdown-display':
specifier: ^8.1.0
version: 8.1.0([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
expo:
specifier: ~54.0.31
version: 54.0.33(@babel/[email protected])(@expo/[email protected])([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
expo-clipboard:
specifier: ^55.0.8
version: 55.0.9([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
expo-constants:
specifier: ~18.0.13
version: 18.0.13([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))
@@ -56,8 +71,14 @@ importers:
expo-web-browser:
specifier: ~15.0.10
version: 15.0.10([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))
immer:
specifier: ^11.1.4
version: 11.1.4
lucide-react-native:
specifier: ^0.576.0
version: 0.576.0([email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]))([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
react:
specifier: 19.1.0
specifier: ^19.1.0
version: 19.1.0
react-dom:
specifier: 19.1.0
@@ -77,12 +98,24 @@ importers:
react-native-screens:
specifier: ~4.16.0
version: 4.16.0([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
react-native-sse:
specifier: ^1.2.1
version: 1.2.1
react-native-svg:
specifier: 15.12.1
version: 15.12.1([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
react-native-web:
specifier: ~0.21.0
version: 0.21.2([email protected]([email protected]))([email protected])
react-native-worklets:
specifier: 0.5.1
version: 0.5.1(@babel/[email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
use-sync-external-store:
specifier: ^1.6.0
version: 1.6.0([email protected])
zustand:
specifier: ^5.0.11
version: 5.0.12(@types/[email protected])([email protected])([email protected])([email protected]([email protected]))
devDependencies:
'@types/react':
specifier: ~19.1.0
@@ -1087,6 +1120,11 @@ packages:
'@types/react':
optional: true
'@react-native-async-storage/[email protected]':
resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==}
peerDependencies:
react-native: ^0.0.0-0 || >=0.65 <1.0
'@react-native/[email protected]':
resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==}
engines: {node: '>= 20.19.4'}
@@ -1196,6 +1234,12 @@ packages:
'@react-navigation/[email protected]':
resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==}
'@ronradtke/[email protected]':
resolution: {integrity: sha512-pAtefWI76vpkxsEgIFivyq1q6ej8rDyR7oVM/cWAxUydyBej9LOvULjLAeFuFLbYAelHTNoYXmGxQOlFLBa0+w==}
peerDependencies:
react: '>=16.2.0'
react-native: '>=0.50.4'
'@rtsao/[email protected]':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@@ -1648,6 +1692,9 @@ packages:
resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
engines: {node: '>=0.6'}
[email protected]:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
[email protected]:
resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==}
@@ -1715,6 +1762,9 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
[email protected]:
resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==}
@@ -1827,9 +1877,27 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
[email protected]:
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
engines: {node: '>=4'}
[email protected]:
resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==}
[email protected]:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
[email protected]:
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
[email protected]:
resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==}
engines: {node: '>=8.0.0'}
[email protected]:
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -1919,6 +1987,19 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
[email protected]:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
[email protected]:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
[email protected]:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
[email protected]:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
engines: {node: '>=12'}
@@ -1948,6 +2029,14 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
[email protected]:
resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==}
engines: {node: '>=0.12'}
[email protected]:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
[email protected]:
resolution: {integrity: sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==}
engines: {node: '>=8'}
@@ -2142,6 +2231,13 @@ packages:
react: '*'
react-native: '*'
[email protected]:
resolution: {integrity: sha512-WJ9ougE8fEDu3/RV5Vz3gE5aNgnj1C0WByXCz3WUj8y/06bJyaIUDMq8FDLv3n0AO3guiErmkUunsD0EzSDUUw==}
peerDependencies:
expo: '*'
react: '*'
react-native: '*'
[email protected]:
resolution: {integrity: sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==}
peerDependencies:
@@ -2537,6 +2633,9 @@ packages:
engines: {node: '>=16.x'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
[email protected]:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -2644,6 +2743,10 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
[email protected]:
resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -2881,6 +2984,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
[email protected]:
resolution: {integrity: sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==}
[email protected]:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@@ -2916,9 +3022,20 @@ packages:
[email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
[email protected]:
resolution: {integrity: sha512-z38PwB7Rknhs1FrT1oMTp1MThnXpv//UH0usmhHAL8DrMoaBtKOKtRvdJOUleHmQZXMX8NcjgzOkelhCilUIlQ==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-native: '*'
react-native-svg: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0
[email protected]:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
[email protected]:
resolution: {integrity: sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
@@ -2926,12 +3043,22 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
[email protected]:
resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==}
[email protected]:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
[email protected]:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
[email protected]:
resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@@ -3175,6 +3302,9 @@ packages:
resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==}
engines: {node: ^16.14.0 || >=18.0.0}
[email protected]:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
[email protected]:
resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==}
@@ -3428,6 +3558,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==}
[email protected]:
resolution: {integrity: sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==}
[email protected]:
resolution: {integrity: sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==}
peerDependencies:
@@ -3460,6 +3593,15 @@ packages:
react: '*'
react-native: '*'
[email protected]:
resolution: {integrity: sha512-zejanlScF+IB9tYnbdry0MT34qjBXbiV/E72qGz33W/tX1bx8MXsbB4lxiuPETc9v/008vYZ60yjIstW22VlVg==}
[email protected]:
resolution: {integrity: sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==}
peerDependencies:
react: '*'
react-native: '*'
[email protected]:
resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==}
peerDependencies:
@@ -3963,6 +4105,9 @@ packages:
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
[email protected]:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -4205,6 +4350,24 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots:
'@0no-co/[email protected]': {}
@@ -5485,6 +5648,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.17
'@react-native-async-storage/[email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))':
dependencies:
merge-options: 3.0.4
react-native: 0.81.5(@babel/[email protected])(@types/[email protected])([email protected])
'@react-native/[email protected]': {}
'@react-native/[email protected](@babel/[email protected])':
@@ -5669,6 +5837,15 @@ snapshots:
dependencies:
nanoid: 3.3.11
'@ronradtke/[email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])':
dependencies:
css-to-react-native: 3.2.0
markdown-it: 13.0.2
prop-types: 15.8.1
react: 19.1.0
react-native: 0.81.5(@babel/[email protected])(@types/[email protected])([email protected])
react-native-fit-image: 1.5.5
'@rtsao/[email protected]': {}
'@sinclair/[email protected]': {}
@@ -6197,6 +6374,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
stream-buffers: 2.2.0
@@ -6270,6 +6449,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -6400,10 +6581,33 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
[email protected]: {}
[email protected]:
dependencies:
hyphenate-style-name: 1.1.0
[email protected]:
dependencies:
boolbase: 1.0.0
css-what: 6.2.2
domhandler: 5.0.3
domutils: 3.2.2
nth-check: 2.1.1
[email protected]:
dependencies:
camelize: 1.0.1
css-color-keywords: 1.0.0
postcss-value-parser: 4.2.0
[email protected]:
dependencies:
mdn-data: 2.0.14
source-map: 0.6.1
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -6474,6 +6678,24 @@ snapshots:
dependencies:
esutils: 2.0.3
[email protected]:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
[email protected]: {}
[email protected]:
dependencies:
domelementtype: 2.3.0
[email protected]:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
[email protected]:
dependencies:
dotenv: 16.4.7
@@ -6496,6 +6718,10 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -6812,6 +7038,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected]([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]):
dependencies:
expo: 54.0.33(@babel/[email protected])(@expo/[email protected])([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
react: 19.1.0
react-native: 0.81.5(@babel/[email protected])(@types/[email protected])([email protected])
[email protected]([email protected])([email protected](@babel/[email protected])(@types/[email protected])([email protected])):
dependencies:
'@expo/config': 12.0.13
@@ -7237,6 +7469,8 @@ snapshots:
dependencies:
queue: 6.0.2
[email protected]: {}
[email protected]:
dependencies:
parent-module: 1.0.1
@@ -7346,6 +7580,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
call-bound: 1.0.4
@@ -7595,6 +7831,10 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
uc.micro: 1.0.6
[email protected]:
dependencies:
p-locate: 4.1.0
@@ -7625,18 +7865,40 @@ snapshots:
dependencies:
yallist: 3.1.1
[email protected]([email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]))([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]):
dependencies:
react: 19.1.0
react-native: 0.81.5(@babel/[email protected])(@types/[email protected])([email protected])
react-native-svg: 15.12.1([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
[email protected]:
dependencies:
tmpl: 1.0.5
[email protected]:
dependencies:
argparse: 2.0.1
entities: 3.0.1
linkify-it: 4.0.1
mdurl: 1.0.1
uc.micro: 1.0.6
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
is-plain-obj: 2.1.0
[email protected]: {}
[email protected]:
@@ -8081,6 +8343,10 @@ snapshots:
semver: 7.7.4
validate-npm-package-name: 5.0.1
[email protected]:
dependencies:
boolbase: 1.0.0
[email protected]: {}
[email protected]:
@@ -8337,6 +8603,10 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
prop-types: 15.8.1
[email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]):
dependencies:
'@egjs/hammerjs': 2.0.17
@@ -8372,6 +8642,16 @@ snapshots:
react-native-is-edge-to-edge: 1.3.1([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected])
warn-once: 0.1.1
[email protected]: {}
[email protected]([email protected](@babel/[email protected])(@types/[email protected])([email protected]))([email protected]):
dependencies:
css-select: 5.2.2
css-tree: 1.1.3
react: 19.1.0
react-native: 0.81.5(@babel/[email protected])(@types/[email protected])([email protected])
warn-once: 0.1.1
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
'@babel/runtime': 7.28.6
@@ -8973,6 +9253,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
call-bound: 1.0.4
@@ -9205,3 +9487,10 @@ snapshots:
yargs-parser: 21.1.1
[email protected]: {}
[email protected](@types/[email protected])([email protected])([email protected])([email protected]([email protected])):
optionalDependencies:
'@types/react': 19.1.17
immer: 11.1.4
react: 19.1.0
use-sync-external-store: 1.6.0([email protected])
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
const exampleDir = "app-example";
const newAppDir = "app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);
+21 -39
View File
@@ -9,18 +9,12 @@ import {
StreamChunk,
} from './types';
export function buildBaseUrl(config: ServerConfig): string {
function buildBaseUrl(config: ServerConfig): string {
const host = config.host.replace(/\/+$/, '');
const protocol = /^https?:\/\//i.test(host) ? '' : 'http://';
const protocol = host.startsWith('http') ? '' : 'http://';
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> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -32,19 +26,19 @@ function buildHeaders(config: ServerConfig): Record<string, string> {
}
export async function testConnection(config: ServerConfig): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(`${buildBaseUrl(config)}/models`, {
headers: buildHeaders(config),
signal: controller.signal,
});
clearTimeout(timeout);
return res.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
@@ -90,7 +84,7 @@ export async function chatCompletion(
}
// Parse SSE lines from a text buffer
export function parseSSELines(
function parseSSELines(
buffer: string,
onChunk: (content: string) => void,
onDone: () => void,
@@ -101,9 +95,9 @@ export function parseSSELines(
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const payload = trimmed.slice(5).trim();
const payload = trimmed.slice(6);
if (payload === '[DONE]') {
isDone = true;
onDone();
@@ -129,7 +123,6 @@ function streamWithXHR(
url: string,
headers: Record<string, string>,
body: string,
baseUrl: string,
onChunk: (content: string) => void,
onDone: () => void,
onError: (error: Error) => void,
@@ -153,10 +146,6 @@ function streamWithXHR(
};
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
onError(new Error(`Stream failed: ${xhr.status} ${xhr.statusText}`));
return;
}
// Process any remaining buffer
if (buffer.trim()) {
parseSSELines(buffer + '\n', onChunk, onDone);
@@ -165,7 +154,7 @@ function streamWithXHR(
};
xhr.onerror = () => {
onError(connectionError(baseUrl));
onError(new Error(`XHR streaming failed: ${xhr.status} ${xhr.statusText}`));
};
xhr.ontimeout = () => {
@@ -186,23 +175,17 @@ async function streamWithFetch(
url: string,
headers: Record<string, string>,
body: string,
baseUrl: string,
onChunk: (content: string) => void,
onDone: () => void,
onError: (error: Error) => void,
signal: AbortSignal,
): Promise<void> {
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers,
body,
signal,
});
} catch (err) {
if ((err as Error).name === 'AbortError') throw err;
throw connectionError(baseUrl);
}
const res = await fetch(url, {
method: 'POST',
headers,
body,
signal,
});
if (!res.ok) {
throw new Error(`Stream failed: ${res.status} ${res.statusText}`);
@@ -246,8 +229,7 @@ export function chatCompletionStream(
...options,
};
const baseUrl = buildBaseUrl(config);
const url = `${baseUrl}/chat/completions`;
const url = `${buildBaseUrl(config)}/chat/completions`;
const headers = buildHeaders(config);
const bodyStr = JSON.stringify(body);
@@ -263,7 +245,7 @@ export function chatCompletionStream(
// Web: use fetch + ReadableStream
const controller = new AbortController();
streamWithFetch(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, controller.signal)
streamWithFetch(url, headers, bodyStr, onChunk, safeOnDone, onError, controller.signal)
.catch((err) => {
if ((err as Error).name !== 'AbortError') {
onError(err as Error);
@@ -273,6 +255,6 @@ export function chatCompletionStream(
return { abort: () => controller.abort() };
} else {
// Native: use XMLHttpRequest
return streamWithXHR(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, onError);
return streamWithXHR(url, headers, bodyStr, onChunk, safeOnDone, onError);
}
}
+190
View File
@@ -0,0 +1,190 @@
/**
* webSearchLogger.ts
* Centralized structured logger for the web search pipeline on the client side.
* Outputs consistently formatted, emoji-tagged logs to the React Native / Metro console.
*/
type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG';
const ICONS: Record<LogLevel, string> = {
INFO: 'ℹ️ ',
WARN: '⚠️ ',
ERROR: '❌',
DEBUG: '🔍',
};
function formatMeta(meta?: Record<string, any>): string {
if (!meta) return '';
try {
return '\n ' + JSON.stringify(meta, null, 2).replace(/\n/g, '\n ');
} catch {
return '';
}
}
function log(level: LogLevel, phase: string, message: string, meta?: Record<string, any>) {
const ts = new Date().toISOString();
const metaStr = formatMeta(meta);
const prefix = `[${ts}] ${ICONS[level]} [WebSearch/${phase}]`;
const fullMsg = `${prefix} ${message}${metaStr}`;
switch (level) {
case 'ERROR':
console.error(fullMsg);
break;
case 'WARN':
console.warn(fullMsg);
break;
default:
console.log(fullMsg);
}
}
function logSection(title: string) {
const border = '─'.repeat(55);
console.log(`\n${border}`);
console.log(` ${title}`);
console.log(`${border}`);
}
/** Rough tokens estimate (~4 chars per token) */
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
// ─── Public API ────────────────────────────────────────────────────────────────
export const WebSearchLogger = {
pipelineStart(query: string, searchEndpoint: string) {
logSection(`🚀 Web Search Pipeline Started`);
log('INFO', 'Init', `New search initiated`, {
query,
searchEndpoint,
timestamp: new Date().toISOString(),
});
},
endpointResolved(host: string, port: number, searchEndpoint: string) {
log('INFO', 'Endpoint', `Search service URL resolved`, {
lmStudioHost: host,
lmStudioPort: port,
searchServiceEndpoint: searchEndpoint,
});
},
requestSent(query: string, maxContextLength: number, timeoutMs = 30000) {
log('INFO', 'Request', `Sending search request to local service`, {
query,
maxContextLength,
estimatedTokenBudget: estimateTokens('x'.repeat(maxContextLength)),
timeoutMs,
});
},
requestFailed(status: number, statusText: string) {
log('ERROR', 'Request', `Search service returned error`, {
httpStatus: status,
statusText,
});
},
serviceUnreachable(error: unknown, isTimeout = false) {
const err = error instanceof Error ? error : new Error(String(error));
const isConnectionRefused =
err.message.includes('Network request failed') ||
err.message.includes('ECONNREFUSED') ||
err.message.includes('Connection refused');
const message = isTimeout
? `Search service timeout (30s) — service may be overloaded or unreachable`
: isConnectionRefused
? `Search service is NOT running — connection refused`
: `Cannot reach local search service`;
log('ERROR', 'Request', message, {
error: err.message,
isTimeout,
isConnectionRefused,
diagnosis: isConnectionRefused
? 'The local-search-service Node.js process is not running on this machine'
: isTimeout
? 'Service is running but not responding within 30s (SearXNG may be slow)'
: 'Unknown network error',
hint: 'Run: cd local-search-service && npx ts-node server.ts',
});
},
contextReceived(
context: string,
sources: Array<{ title: string; url: string }>,
meta?: {
requestId?: string;
totalLatencyMs?: number;
finalContextChars?: number;
finalContextEstimatedTokens?: number;
sourcesKept?: number;
sourcesDropped?: number;
pruned?: boolean;
},
) {
logSection(`✅ Context Received from Search Service`);
log('INFO', 'Context', `Web search context ready`, {
contextChars: context.length,
estimatedTokens: estimateTokens(context),
sourcesReceived: sources.length,
sourceTitles: sources.map((s) => s.title.substring(0, 60)),
sourceUrls: sources.map((s) => s.url),
...(meta ?? {}),
});
},
contextAnalysis(systemPromptChars: number, historyChars: number, searchContextChars: number) {
logSection(`🧠 Context Window Analysis`);
const totalChars = systemPromptChars + historyChars + searchContextChars;
const totalTokens = estimateTokens('x'.repeat(totalChars));
log('INFO', 'Analysis', `Total context before sending to LLM`, {
systemPromptChars,
systemPromptTokens: estimateTokens('x'.repeat(systemPromptChars)),
chatHistoryChars: historyChars,
chatHistoryTokens: estimateTokens('x'.repeat(historyChars)),
searchContextChars,
searchContextTokens: estimateTokens('x'.repeat(searchContextChars)),
totalChars,
estimatedTotalTokens: totalTokens,
note: 'Token estimates are approximate (~4 chars/token). Actual tokenization varies by model.',
});
},
llmRequestStart(model: string, messageCount: number) {
logSection(`🤖 LLM Request`);
log('INFO', 'LLM', `Sending messages to LM Studio`, {
model,
totalMessages: messageCount,
streamingEnabled: true,
});
},
llmStreamComplete(assistantMessageId: string, chunkCount?: number) {
log('INFO', 'LLM', `Stream complete — response generated`, {
assistantMessageId,
...(chunkCount !== undefined ? { chunkCount } : {}),
});
},
llmError(error: Error) {
log('ERROR', 'LLM', `LLM stream error`, {
error: error.message,
stack: error.stack?.split('\n').slice(0, 3).join(' | '),
});
},
pipelineComplete(query: string, totalLatencyMs: number, withSearch: boolean) {
logSection(`🏁 Pipeline Complete`);
log('INFO', 'Done', `Full request cycle finished`, {
query,
totalLatencyMs,
webSearchUsed: withSearch,
});
console.log('─'.repeat(55) + '\n');
},
};
-54
View File
@@ -1,54 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppState } from 'react-native';
import type { StateStorage } from 'zustand/middleware';
export interface DebouncedStorage extends StateStorage {
/** Writes every pending value right away. */
flush: () => Promise<void>;
}
/**
* Wraps a storage so bursts of writes collapse into one trailing write.
*
* The chat store is persisted on every state change and a streamed answer
* changes it once per token, which would serialize every saved session and hit
* AsyncStorage dozens of times per second.
*/
export function createDebouncedStorage(base: StateStorage, delayMs = 400): DebouncedStorage {
const pending = new Map<string, string>();
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = async () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
const entries = [...pending.entries()];
pending.clear();
await Promise.all(entries.map(([name, value]) => base.setItem(name, value)));
};
return {
getItem: (name) => {
const value = pending.get(name);
return value === undefined ? base.getItem(name) : Promise.resolve(value);
},
setItem: (name, value) => {
pending.set(name, value);
if (timer) clearTimeout(timer);
timer = setTimeout(flush, delayMs);
},
removeItem: (name) => {
pending.delete(name);
return base.removeItem(name);
},
flush,
};
}
export const appStorage = createDebouncedStorage(AsyncStorage);
// Do not lose the last tokens when the app is sent to the background or closed.
AppState.addEventListener('change', (state) => {
if (state !== 'active') void appStorage.flush();
});
+10 -3
View File
@@ -1,7 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { DEFAULT_SERVER_CONFIG, LMModel, ServerConfig } from '../services/types';
import { appStorage } from './debouncedStorage';
export interface ChatMessage {
id: string;
@@ -49,6 +49,10 @@ interface AppState {
isStreaming: boolean;
setIsStreaming: (streaming: boolean) => void;
// Web Search
isWebSearchEnabled: boolean;
setIsWebSearchEnabled: (enabled: boolean) => void;
// Edit mode
editingMessageId: string | null;
setEditingMessageId: (id: string | null) => void;
@@ -119,6 +123,9 @@ export const useAppStore = create<AppState>()(
isStreaming: false,
setIsStreaming: (isStreaming) => set({ isStreaming }),
isWebSearchEnabled: false,
setIsWebSearchEnabled: (isWebSearchEnabled) => set({ isWebSearchEnabled }),
editingMessageId: null,
setEditingMessageId: (editingMessageId) => set({ editingMessageId }),
@@ -164,14 +171,14 @@ export const useAppStore = create<AppState>()(
}),
{
name: 'localaiapp-storage',
storage: createJSONStorage(() => appStorage),
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
theme: state.theme,
serverConfig: state.serverConfig,
activeModelId: state.activeModelId,
chatSessions: state.chatSessions,
messages: state.messages,
currentChatId: state.currentChatId,
isWebSearchEnabled: state.isWebSearchEnabled,
}),
}
)
-16
View File
@@ -1,16 +0,0 @@
import type { ChatMessage } from '../store/useAppStore';
/** Id of the most recent assistant message, or null when there is none. */
export function findLastAssistantId(messages: ChatMessage[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') return messages[i].id;
}
return null;
}
/** True while the model has been asked to answer but has not produced any text yet. */
export function shouldShowTypingIndicator(messages: ChatMessage[], isStreaming: boolean): boolean {
if (!isStreaming) return false;
const last = messages[messages.length - 1];
return last?.role === 'assistant' && last.content === '';
}
-25
View File
@@ -1,25 +0,0 @@
import { DEFAULT_SERVER_CONFIG, ServerConfig } from '../services/types';
export interface ServerConfigForm {
host: string;
port: string;
apiKey: string;
}
/** Turns the raw text fields of the settings form into a usable ServerConfig. */
export function buildServerConfig(form: ServerConfigForm): ServerConfig {
return {
host: form.host.trim() || DEFAULT_SERVER_CONFIG.host,
port: parseInt(form.port, 10) || DEFAULT_SERVER_CONFIG.port,
apiKey: form.apiKey.trim() || undefined,
};
}
/** True when the form differs from what is currently stored. */
export function isConfigChanged(form: ServerConfigForm, saved: ServerConfig): boolean {
return (
form.host !== saved.host ||
form.port !== saved.port.toString() ||
form.apiKey !== (saved.apiKey ?? '')
);
}
-7
View File
@@ -1,7 +0,0 @@
/** Whether the dark theme is in effect for a stored preference and the OS scheme. */
export function isDarkTheme(
preference: 'light' | 'dark' | 'system',
systemScheme: 'light' | 'dark',
): boolean {
return preference === 'dark' || (preference === 'system' && systemScheme === 'dark');
}