Compare commits

..
Author SHA1 Message Date
evandro c8d7d61e9a docs: update README for tests, CI, scripts and the current project structure 2026-10-10 04:34:33 -03:00
evandro 541aefd7e4 feat: check the server on launch and remember the selected model
The chat showed 'Server offline' until the user opened Settings, and the selected model was forgotten on every restart. useAutoConnect fetches the models once after the persisted settings are restored; activeModelId is now persisted.
2026-10-10 04:31:21 -03:00
evandro 1a3ce9ccec fix(chat): do not delete the answer when regenerating without a selected model
regenerateResponse removed the previous answer before checking for a model and then returned silently, leaving the conversation without a reply. It now checks first and explains that a model is required, like sendMessage. The duplicated streaming code in sendMessage and regenerateResponse is now a single streamAnswer helper.
2026-10-10 04:31:21 -03:00
evandro b441339bb4 feat(a11y): label icon-only buttons and inputs for screen readers
Header, message action, send/stop, history, model selector and dark-mode controls had no accessible name (icon-only buttons). Adds roles/labels and selected/disabled state; no visual change.
2026-10-10 04:29:33 -03:00
evandro 0e5b38fd15 perf(chat): debounce persisted writes and re-render only the streaming message
- the Zustand persist middleware serialized every saved session and wrote to AsyncStorage on each streamed token (appendToMessage -> set); writes are now debounced (400 ms) and flushed when the app leaves the foreground
- every token re-rendered every MessageBubble, re-parsing the Markdown of the whole conversation; rows are memoized so only the changed message renders (covered by MessageList.test.tsx)
2026-10-10 04:28:38 -03:00
evandro 200fb6afd7 chore: remove unused Expo template files and dependencies, add CI workflow
- 17 files unreferenced by the app (knip): hello-wave, parallax-scroll-view, themed-*, icon-symbol, Button, GlassCard, ScreenWrapper, Models catalog, duplicated theme/color-scheme hooks
- scripts/reset-project.js and its npm script: it moves app/ to app-example and would wipe the project
- dependencies only used by those files: expo-image, expo-symbols, expo-web-browser, react-native-sse, @babel/plugin-transform-runtime
- GitHub Actions workflow running typecheck, lint, tests and the web export
2026-10-10 04:26:21 -03:00
evandro 7ee78af76d chore(deps): align packages with Expo SDK 54 (react 19.1.0, expo-clipboard 8.x, patch bumps)
- react was ^19.1.0 and resolved to 19.3.0 while react-dom and the React Native renderer are 19.1.0; pin 19.1.0
- expo-clipboard ^55 is the SDK 55 release; SDK 54 expects ~8.0.8 (npx expo install --check)
- patch bumps for expo, expo-router, expo-constants, expo-font, expo-linking, expo-web-browser
- babel-preset-expo is required by babel.config.js, so declare it
2026-10-10 04:24:40 -03:00
evandro 2a294f1f87 refactor(chat): split MessageBubble into avatar, content and code block components
MessageBubble CC 19 -> below 10. The fence and indented code block rules were identical and are now one CodeBlock component; the Markdown style map lives in markdownStyles.ts. Adds characterization tests for the bubble (roles, actions visibility, edited badge, copy feedback).
2026-10-10 04:20:59 -03:00
evandro 434cba2a69 refactor(settings): split SettingsScreen into section components and pure helpers
SettingsScreen CC 23 -> below 10. Form parsing, change detection and the dark-theme check moved to utils/ with unit tests; the dark switch now reads the OS scheme instead of comparing the background colour. Adds characterization tests for the screen.
2026-10-10 04:18:40 -03:00
evandro cb6eda0562 refactor(chat): split ChatScreen and reuse regenerateResponse after editing a message
The edit path duplicated the streaming flow with inline require() calls; it now reuses regenerateResponse, which also fixes:
- edited conversations were never saved to History
- the answer generated after an edit could not be stopped (no abort handle kept)
Stopping a stream now saves the partial conversation, like finishing or failing does.
Adds characterization tests for ChatScreen and useLMStudio.
2026-10-10 04:16:29 -03:00
evandro 808cb1610b fix(api): handle hosts starting with http, HTTP errors on XHR and clearer connection errors
- buildBaseUrl treated any host starting with 'http' (e.g. httpbin.local) as having a scheme
- native streaming finished silently on 4xx/5xx; it now reports the status
- network failures now explain how to fix the connection instead of 'XHR streaming failed: 0'
- testConnection left its 5 s timer running on failure
- SSE parser accepts 'data:' without a space, as the spec allows
2026-10-10 04:12:13 -03:00
evandro 37dfa265f5 test: set up jest-expo and cover the Zustand store 2026-10-10 04:12:13 -03:00
Evandro Fonseca Junior 1e0490b1f7 docs: rewrite README with verified setup, web deploy and limitations
Documents only what was executed on Linux (Node 24, npm): install, tsc, expo lint,
web export and a headless-browser E2E against a mock of the LM Studio /v1 API.
Also fixes the clone URL, which pointed at the old LocalAiApp repository name.
2026-09-14 07:55:37 -03:00
Evandro Fonseca Junior 7c624cdce8 chore: drop committed junk files and ignore python caches
- remove the stray '1.2.0' file at the root, an npm install log committed by accident.
- remove the committed __pycache__/*.pyc binaries and ignore __pycache__/ and *.pyc.
- add .gitattributes marking .agent/** as vendored/documentation so GitHub stops
  reporting the repository as Python when the app is TypeScript.
2026-09-14 07:55:33 -03:00
Evandro Fonseca Junior 4cc1addb3c fix: make the web bundle run and repair broken route declarations
- add babel.config.js with unstable_transformImportMeta: zustand/middleware ships
  import.meta.env, which the Metro web bundle (a classic script) cannot parse, so
  every page threw 'SyntaxError: Cannot use import.meta outside a module' and the
  app never hydrated after the static export.
- declare the settings screen as 'settings/index' in the root stack: the old name
  'settings' matched no route, so the modal presentation and the 'Settings' title
  were silently dropped (expo-router logged 'No route named settings exists').
- add the missing app/+not-found.tsx that the root layout already referenced.
- update the About label from LocalAiApp to LocalMind.
2026-09-14 07:55:29 -03:00
78 changed files with 18889 additions and 4659 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# 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
@@ -0,0 +1,21 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm test
- run: npm run build
+4
View File
@@ -36,6 +36,10 @@ yarn-error.*
# typescript
*.tsbuildinfo
# python (scripts de apoio em .agent/)
__pycache__/
*.pyc
app-example
# generated native folders
-21
View File
@@ -1,21 +0,0 @@
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
@@ -1,12 +0,0 @@
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.
+156 -45
View File
@@ -1,79 +1,190 @@
# LocalMind
![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)
[![CI](https://github.com/evandrodevbr/LocalMind/actions/workflows/ci.yml/badge.svg)](https://github.com/evandrodevbr/LocalMind/actions/workflows/ci.yml)
> A fully local, privacy-first AI companion app built with React Native and Expo. Your data never leaves your device.
**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.**
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.
![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)
*Roadmap: Future updates will introduce direct in-app model downloads and support for external APIs.*
## About
---
Running a model locally in LM Studio is easy; having a decent chat UI for it on the phone is not. LocalMind is that client: it talks straight to the LM Studio local server (the OpenAI-compatible `/v1` endpoints) over your LAN or `localhost`, streams the answer token by token, keeps chat sessions on the device and renders Markdown with code blocks.
## Features
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.
- **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.
## How it works
---
```
Expo app (Android / iOS / web)
app/chat, app/settings ......... screens (expo-router, file based)
hooks/useLMStudio .............. sendMessage / regenerate / stop, stream callbacks
services/lmStudioApi ........... HTTP + SSE client
store/useAppStore .............. Zustand + AsyncStorage (sessions, settings, theme)
|
| GET /v1/models
| POST /v1/chat/completions (stream: true -> SSE)
v
LM Studio local server (default http://localhost:1234)
```
## Quick Start
- 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.
To run this application, you will need both the frontend development environment and a local AI engine running.
## Stack
### 1. Model Server (LMStudio)
| 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. 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**.
## Requirements
> **Note:** If testing on a physical mobile device, locate your machine's local IPv4 address (e.g., `192.168.1.X`).
- 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
### 2. Mobile Client (App)
Ensure you have [Node.js](https://nodejs.org/) (v18+) and the [Expo Go](https://expo.dev/go) app installed.
## Quick start
```bash
git clone https://github.com/evandrodevbr/LocalAiApp.git
cd LocalAiApp
git clone https://github.com/evandrodevbr/LocalMind.git
cd LocalMind
npm install
npx expo start
```
Scan the QR code with Expo Go, or type `i`/`a` to launch a simulator.
Then pick a target from the Expo CLI: `w` for web, `a` for Android, `i` for iOS, or scan the QR code with Expo Go.
### 3. Connection
Start the model side first:
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**.
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**.
Once connected, your available models will load automatically.
Finally, in the app:
---
1. Open **Settings** (gear icon in the chat header).
2. Fill **Host IP / Address** and **Port Number**. On a physical device this must be your machine's LAN address (`192.168.x.x`), not `localhost`.
3. Tap **Test Connection**. The badge turns to *Connected* and the model list becomes available in the chat header selector.
## Bug Reports & Contributing
## Usage
Found an issue or have a suggestion? We welcome community involvement!
| 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. |
👉 **[Report an Issue or Request a Feature](https://github.com/evandrodevbr/LocalAiApp/issues)**
## API
When creating an issue, please include:
The app is a client of the LM Studio OpenAI-compatible API. Endpoints actually used:
- A clear description of the bug.
- Steps to reproduce.
- Relevant screenshots or environment details.
| Call | Where | Notes |
|---|---|---|
| `GET /v1/models` | `testConnection()`, `getModels()` | The connection test aborts after 5 s; the model list feeds the header selector. |
| `POST /v1/chat/completions` | `chatCompletion()`, `chatCompletionStream()` | Streaming (`stream: true`) is the path used by the chat; SSE lines with `choices[0].delta.content` are appended to the last message. |
---
- Base URL: `http://<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": ... } ] }`.
## Acknowledgments
## Web target
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.
The same codebase exports a static web build:
<br>
<br>
```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.
+39
View File
@@ -0,0 +1,39 @@
import { fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';
import { ChatInput } from '../components/chat/ChatInput';
describe('ChatInput', () => {
it('sends trimmed text, clears the field and disables send while empty', () => {
const onSend = jest.fn();
render(<ChatInput onSend={onSend} />);
expect(screen.getByLabelText('Send message').props.accessibilityState.disabled).toBe(true);
fireEvent.changeText(screen.getByLabelText('Message'), ' hello ');
fireEvent.press(screen.getByLabelText('Send message'));
expect(onSend).toHaveBeenCalledWith('hello');
expect(screen.getByLabelText('Message').props.value).toBe('');
});
it('shows a stop button instead of send while loading', () => {
const onStop = jest.fn();
render(<ChatInput onSend={jest.fn()} onStop={onStop} isLoading />);
expect(screen.queryByLabelText('Send message')).toBeNull();
fireEvent.press(screen.getByLabelText('Stop generating'));
expect(onStop).toHaveBeenCalledTimes(1);
});
it('fills the field when editing and lets the user cancel', () => {
const onCancelEdit = jest.fn();
render(<ChatInput onSend={jest.fn()} editingText="old text" onCancelEdit={onCancelEdit} />);
expect(screen.getByText('Editing message')).toBeTruthy();
expect(screen.getByLabelText('Message').props.value).toBe('old text');
fireEvent.press(screen.getByLabelText('Cancel editing'));
expect(onCancelEdit).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('Message').props.value).toBe('');
});
});
+221
View File
@@ -0,0 +1,221 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';
import ChatScreen from '../app/chat/index';
import * as api from '../services/lmStudioApi';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
jest.mock('react-native-reanimated', () => {
const { View } = require('react-native');
const chain: any = new Proxy(() => chain, { get: () => chain });
return { __esModule: true, default: { View }, FadeInDown: chain };
});
jest.mock('../components/chat/MessageBubble', () => {
const { Text } = require('react-native');
return {
MessageBubble: ({ message, isLastAssistant }: any) => (
<Text testID={`bubble-${message.id}`}>{`${message.role}:${message.content}${isLastAssistant ? ':last' : ''}`}</Text>
),
};
});
jest.mock('../components/chat/TypingIndicator', () => {
const { Text } = require('react-native');
return { TypingIndicator: () => <Text>typing-indicator</Text> };
});
let inputProps: any;
jest.mock('../components/chat/ChatInput', () => ({
ChatInput: (props: any) => {
inputProps = props;
return null;
},
}));
const streamMock = api.chatCompletionStream as jest.Mock;
const state = () => useAppStore.getState();
beforeEach(() => {
jest.useFakeTimers();
streamMock.mockReset();
useAppStore.setState(useAppStore.getInitialState(), true);
useAppStore.setState({ isConnected: true, activeModelId: 'model-a' });
});
afterEach(() => {
jest.useRealTimers();
});
describe('ChatScreen', () => {
it('shows the empty state with prompt suggestions when there are no messages', () => {
render(<ChatScreen />);
expect(screen.getByText('Start a conversation')).toBeTruthy();
expect(screen.getByText('Explain recursion in simple terms')).toBeTruthy();
});
it('sends a suggestion as a user message and starts streaming', () => {
render(<ChatScreen />);
fireEvent.press(screen.getByText('Explain recursion in simple terms'));
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual([
'user:Explain recursion in simple terms',
'assistant:',
]);
expect(streamMock).toHaveBeenCalledTimes(1);
expect(streamMock.mock.calls[0][1]).toBe('model-a');
});
it('hides the empty state and lists the messages once there are some', () => {
state().addMessage({ role: 'user', content: 'hello' });
render(<ChatScreen />);
expect(screen.queryByText('Start a conversation')).toBeNull();
expect(screen.getByText('user:hello')).toBeTruthy();
});
it('shows the offline banner only when the server is not connected', () => {
const { rerender } = render(<ChatScreen />);
expect(screen.queryByText(/Server offline/)).toBeNull();
act(() => {
state().setIsConnected(false);
});
rerender(<ChatScreen />);
expect(screen.getByText(/Server offline/)).toBeTruthy();
});
it('shows the typing indicator only while an empty assistant message is streaming', () => {
state().addMessage({ role: 'user', content: 'q' });
const assistantId = state().addMessage({ role: 'assistant', content: '' });
act(() => {
state().setIsStreaming(true);
});
render(<ChatScreen />);
expect(screen.getByText('typing-indicator')).toBeTruthy();
act(() => {
state().appendToMessage(assistantId, 'partial');
});
expect(screen.queryByText('typing-indicator')).toBeNull();
});
it('does not show the typing indicator when not streaming', () => {
state().addMessage({ role: 'assistant', content: '' });
render(<ChatScreen />);
expect(screen.queryByText('typing-indicator')).toBeNull();
});
it('marks only the last assistant message as regenerable', () => {
state().addMessage({ role: 'user', content: 'q1' });
state().addMessage({ role: 'assistant', content: 'a1' });
state().addMessage({ role: 'user', content: 'q2' });
state().addMessage({ role: 'assistant', content: 'a2' });
render(<ChatScreen />);
expect(screen.getByText('assistant:a1')).toBeTruthy();
expect(screen.getByText('assistant:a2:last')).toBeTruthy();
});
it('sends typed text through the normal path', () => {
render(<ChatScreen />);
act(() => {
inputProps.onSend('typed');
});
expect(state().messages[0]).toMatchObject({ role: 'user', content: 'typed' });
expect(streamMock).toHaveBeenCalledTimes(1);
});
describe('editing a message', () => {
const setup = () => {
state().addMessage({ role: 'user', content: 'old question' });
state().addMessage({ role: 'assistant', content: 'old answer' });
const id = state().messages[0].id;
render(<ChatScreen />);
act(() => {
state().setEditingMessageId(id);
});
return id;
};
it('passes the edited message text to the input and clears edit mode on cancel', () => {
setup();
expect(inputProps.editingText).toBe('old question');
act(() => {
inputProps.onCancelEdit();
});
expect(state().editingMessageId).toBeNull();
});
it('rewrites the message, drops the answers after it and regenerates', () => {
const id = setup();
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
expect(state().editingMessageId).toBeNull();
expect(state().messages[0]).toMatchObject({ id, content: 'new question', isEdited: true });
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual([
'user:new question',
'assistant:',
]);
expect(streamMock).toHaveBeenCalledTimes(1);
const [, modelId, apiMessages] = streamMock.mock.calls[0];
expect(modelId).toBe('model-a');
expect(apiMessages.slice(1)).toEqual([{ role: 'user', content: 'new question' }]);
expect(apiMessages[0].role).toBe('system');
});
it('edits and explains that a model is needed when none is selected', () => {
const id = setup();
act(() => {
useAppStore.setState({ activeModelId: '' });
});
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
expect(state().messages).toHaveLength(2);
expect(state().messages[0]).toMatchObject({ id, content: 'new question' });
expect(state().messages[1].content).toContain('No model selected');
expect(streamMock).not.toHaveBeenCalled();
});
it('lets the user stop an answer generated after an edit', () => {
const abort = jest.fn();
streamMock.mockReturnValue({ abort });
setup();
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
act(() => {
inputProps.onStop();
});
expect(abort).toHaveBeenCalledTimes(1);
expect(state().isStreaming).toBe(false);
});
it('saves the edited conversation when the regenerated answer finishes', () => {
streamMock.mockImplementation((_c, _m, _msgs, onChunk, onDone) => {
onChunk('fresh answer');
onDone();
return { abort: jest.fn() };
});
setup();
act(() => {
inputProps.onSend('new question');
});
act(() => {
jest.advanceTimersByTime(100);
});
expect(state().chatSessions).toHaveLength(1);
expect(state().chatSessions[0].messages.map((m) => m.content)).toEqual(['new question', 'fresh answer']);
});
});
});
+154
View File
@@ -0,0 +1,154 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import * as Clipboard from 'expo-clipboard';
import React from 'react';
import { MessageBubble } from '../components/chat/MessageBubble';
import { ChatMessage } from '../store/useAppStore';
jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn(() => Promise.resolve(true)) }));
let actionsProps: any;
jest.mock('../components/chat/MessageActions', () => ({
MessageActions: (props: any) => {
actionsProps = props;
const { Text } = require('react-native');
return <Text>actions</Text>;
},
}));
const message = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
id: '1',
role: 'assistant',
content: 'hello **world**',
timestamp: new Date(2026, 0, 1, 13, 5).getTime(),
...overrides,
});
beforeEach(() => {
actionsProps = undefined;
(Clipboard.setStringAsync as jest.Mock).mockClear();
});
describe('MessageBubble user message', () => {
it('shows the plain text and keeps the actions hidden until touched', () => {
render(<MessageBubble message={message({ role: 'user', content: 'hi there' })} />);
expect(screen.getByText('hi there')).toBeTruthy();
expect(screen.queryByText('actions')).toBeNull();
});
it('reveals edit and delete (but not regenerate) after a touch', () => {
const onEdit = jest.fn();
const onDelete = jest.fn();
const onRegenerate = jest.fn();
render(
<MessageBubble
message={message({ role: 'user', content: 'hi' })}
onEdit={onEdit}
onDelete={onDelete}
onRegenerate={onRegenerate}
isLastAssistant
/>,
);
fireEvent(screen.getByText('hi').parent!.parent!.parent!.parent!, 'touchStart');
expect(screen.getByText('actions')).toBeTruthy();
expect(actionsProps.role).toBe('user');
expect(actionsProps.onEdit).toBe(onEdit);
expect(actionsProps.onDelete).toBe(onDelete);
expect(actionsProps.onRegenerate).toBeUndefined();
});
it('never renders actions for an empty message', () => {
render(<MessageBubble message={message({ role: 'user', content: '' })} />);
fireEvent(screen.getByText(/\d/).parent!.parent!.parent!, 'touchStart');
expect(screen.queryByText('actions')).toBeNull();
});
});
describe('MessageBubble assistant message', () => {
it('renders markdown and always shows actions', () => {
render(<MessageBubble message={message()} />);
expect(screen.getByText('world')).toBeTruthy();
expect(screen.getByText('actions')).toBeTruthy();
});
it('offers regenerate only on the last assistant message and never edit', () => {
const onEdit = jest.fn();
const onRegenerate = jest.fn();
const { rerender } = render(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} />);
expect(actionsProps.onRegenerate).toBeUndefined();
expect(actionsProps.onEdit).toBeUndefined();
rerender(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} isLastAssistant />);
expect(actionsProps.onRegenerate).toBe(onRegenerate);
expect(actionsProps.onEdit).toBeUndefined();
});
it('flags actions as streaming only for the last assistant message', () => {
const { rerender } = render(<MessageBubble message={message()} isStreaming />);
expect(actionsProps.isStreaming).toBeFalsy();
rerender(<MessageBubble message={message()} isStreaming isLastAssistant />);
expect(actionsProps.isStreaming).toBe(true);
});
it('renders nothing inside the bubble or actions while an empty answer is streaming', () => {
render(<MessageBubble message={message({ content: '' })} isStreaming isLastAssistant />);
expect(screen.queryByText('actions')).toBeNull();
});
it('renders an empty answer as markdown when not streaming', () => {
render(<MessageBubble message={message({ content: '' })} />);
expect(screen.queryByText('actions')).toBeNull();
});
});
describe('MessageBubble metadata', () => {
it('shows the time and the edited badge only for edited messages', () => {
const { rerender } = render(<MessageBubble message={message({ role: 'user' })} />);
expect(screen.queryByText(/edited/)).toBeNull();
rerender(<MessageBubble message={message({ role: 'user', isEdited: true })} />);
expect(screen.getByText(/· edited/)).toBeTruthy();
});
});
describe('MessageBubble code blocks', () => {
const fenced = 'text\n\n```ts\nconst a = 1;\n```\n';
it('shows the language, the code and copies it to the clipboard', async () => {
jest.useFakeTimers();
try {
render(<MessageBubble message={message({ content: fenced })} />);
expect(screen.getByText('ts')).toBeTruthy();
expect(screen.getByText(/const a = 1;/)).toBeTruthy();
expect(screen.getByText('Copy')).toBeTruthy();
await act(async () => {
fireEvent.press(screen.getByText('Copy'));
});
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('const a = 1;\n');
expect(screen.getByText('Copied!')).toBeTruthy();
act(() => {
jest.advanceTimersByTime(2000);
});
expect(screen.getByText('Copy')).toBeTruthy();
} finally {
jest.useRealTimers();
}
});
it('labels a fence without a language as "code"', () => {
render(<MessageBubble message={message({ content: '```\nplain\n```\n' })} />);
expect(screen.getByText('code')).toBeTruthy();
});
it('renders indented code blocks with a "code" header and copy button', async () => {
render(<MessageBubble message={message({ content: 'intro\n\n indented()\n' })} />);
expect(screen.getByText('code')).toBeTruthy();
await act(async () => {
fireEvent.press(screen.getByText('Copy'));
});
expect(Clipboard.setStringAsync).toHaveBeenCalledWith(expect.stringContaining('indented()'));
});
});
+78
View File
@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react-native';
import React from 'react';
import { MessageList } from '../components/chat/MessageList';
import { ChatMessage } from '../store/useAppStore';
jest.mock('react-native-reanimated', () => {
const { View } = require('react-native');
const chain: any = new Proxy(() => chain, { get: () => chain });
return { __esModule: true, default: { View }, FadeInDown: chain };
});
const mockRenders: Record<string, number> = {};
jest.mock('../components/chat/MessageBubble', () => {
const { Text } = require('react-native');
return {
MessageBubble: ({ message }: any) => {
mockRenders[message.id] = (mockRenders[message.id] ?? 0) + 1;
return <Text>{`${message.id}:${message.content}`}</Text>;
},
};
});
jest.mock('../components/chat/TypingIndicator', () => {
const { Text } = require('react-native');
return { TypingIndicator: () => <Text>typing-indicator</Text> };
});
const msg = (id: string, role: ChatMessage['role'], content: string): ChatMessage => ({
id,
role,
content,
timestamp: 0,
});
const handlers = { onEdit: jest.fn(), onDelete: jest.fn(), onRegenerate: jest.fn() };
beforeEach(() => {
Object.keys(mockRenders).forEach((k) => delete mockRenders[k]);
});
describe('MessageList', () => {
it('renders every message', () => {
render(
<MessageList
messages={[msg('u1', 'user', 'q'), msg('a1', 'assistant', 'a')]}
isStreaming={false}
{...handlers}
/>,
);
expect(screen.getByText('u1:q')).toBeTruthy();
expect(screen.getByText('a1:a')).toBeTruthy();
});
it('re-renders only the message that changed while an answer streams in', () => {
const user = msg('u1', 'user', 'question');
const earlier = msg('a0', 'assistant', 'earlier answer');
const streaming = msg('a1', 'assistant', 'He');
const { rerender } = render(
<MessageList messages={[user, earlier, streaming]} isStreaming {...handlers} />,
);
expect(mockRenders).toEqual({ u1: 1, a0: 1, a1: 1 });
rerender(
<MessageList messages={[user, earlier, { ...streaming, content: 'Hello' }]} isStreaming {...handlers} />,
);
expect(screen.getByText('a1:Hello')).toBeTruthy();
expect(mockRenders.u1).toBe(1);
expect(mockRenders.a0).toBe(1);
expect(mockRenders.a1).toBe(2);
});
it('shows the typing indicator while the last assistant message is still empty', () => {
render(
<MessageList messages={[msg('u1', 'user', 'q'), msg('a1', 'assistant', '')]} isStreaming {...handlers} />,
);
expect(screen.getByText('typing-indicator')).toBeTruthy();
});
});
+172
View File
@@ -0,0 +1,172 @@
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import React from 'react';
import SettingsScreen from '../app/settings/index';
import * as api from '../services/lmStudioApi';
import { DEFAULT_SERVER_CONFIG } from '../services/types';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
let mockSystemScheme: 'light' | 'dark' = 'light';
jest.mock('../hooks/useColorScheme', () => ({
useColorScheme: () => mockSystemScheme,
}));
const mockApi = api as jest.Mocked<typeof api>;
const state = () => useAppStore.getState();
const hostInput = () => screen.getByPlaceholderText('localhost');
const portInput = () => screen.getByPlaceholderText('1234');
const keyInput = () => screen.getByPlaceholderText('Leave empty for local');
beforeEach(() => {
jest.resetAllMocks();
mockSystemScheme = 'light';
useAppStore.setState(useAppStore.getInitialState(), true);
mockApi.getModels.mockResolvedValue([]);
});
describe('SettingsScreen connection form', () => {
it('is filled from the stored configuration and hides "Save Changes" until something changes', () => {
useAppStore.setState({ serverConfig: { host: '192.168.0.5', port: 4321, apiKey: 'k' } });
render(<SettingsScreen />);
expect(hostInput().props.value).toBe('192.168.0.5');
expect(portInput().props.value).toBe('4321');
expect(keyInput().props.value).toBe('k');
expect(screen.queryByText('Save Changes')).toBeNull();
});
it('shows "Save Changes" for each changed field', () => {
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), 'other');
expect(screen.getByText('Save Changes')).toBeTruthy();
fireEvent.changeText(hostInput(), DEFAULT_SERVER_CONFIG.host);
expect(screen.queryByText('Save Changes')).toBeNull();
fireEvent.changeText(portInput(), '999');
expect(screen.getByText('Save Changes')).toBeTruthy();
fireEvent.changeText(portInput(), String(DEFAULT_SERVER_CONFIG.port));
expect(screen.queryByText('Save Changes')).toBeNull();
fireEvent.changeText(keyInput(), 'secret');
expect(screen.getByText('Save Changes')).toBeTruthy();
});
it('saves trimmed values', () => {
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), ' 192.168.0.9 ');
fireEvent.changeText(portInput(), '8080');
fireEvent.changeText(keyInput(), ' key ');
fireEvent.press(screen.getByText('Save Changes'));
expect(state().serverConfig).toEqual({ host: '192.168.0.9', port: 8080, apiKey: 'key' });
});
it('falls back to localhost:1234 and no API key when the fields are blank or invalid', () => {
useAppStore.setState({ serverConfig: { host: 'lan', port: 4321, apiKey: 'k' } });
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), ' ');
fireEvent.changeText(portInput(), 'abc');
fireEvent.changeText(keyInput(), ' ');
fireEvent.press(screen.getByText('Save Changes'));
expect(state().serverConfig).toEqual({ host: 'localhost', port: 1234, apiKey: undefined });
});
});
describe('SettingsScreen connection test', () => {
it('saves the configuration, reports success and loads the models', async () => {
mockApi.testConnection.mockResolvedValue(true);
render(<SettingsScreen />);
fireEvent.changeText(hostInput(), '10.0.0.2');
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(mockApi.testConnection).toHaveBeenCalledWith({ host: '10.0.0.2', port: 1234, apiKey: undefined });
expect(state().serverConfig.host).toBe('10.0.0.2');
expect(state().isConnected).toBe(true);
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
expect(screen.getByText('Connection successful. API is ready.')).toBeTruthy();
expect(screen.getByText('Connected')).toBeTruthy();
});
it('reports failure and does not load models', async () => {
mockApi.testConnection.mockResolvedValue(false);
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(state().isConnected).toBe(false);
expect(mockApi.getModels).not.toHaveBeenCalled();
expect(screen.getByText('Connection failed. Verify host and port.')).toBeTruthy();
expect(screen.getByText('Disconnected')).toBeTruthy();
});
it('shows a spinner and ignores presses while the test is running', async () => {
let resolveTest: (ok: boolean) => void = () => {};
mockApi.testConnection.mockReturnValue(new Promise((resolve) => (resolveTest = resolve)));
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(screen.queryByText('Test Connection')).toBeNull();
await act(async () => {
resolveTest(false);
});
expect(screen.getByText('Test Connection')).toBeTruthy();
});
it('clears a previous result when the configuration is saved again', async () => {
mockApi.testConnection.mockResolvedValue(false);
render(<SettingsScreen />);
await act(async () => {
fireEvent.press(screen.getByText('Test Connection'));
});
expect(screen.getByText(/Connection failed/)).toBeTruthy();
fireEvent.changeText(hostInput(), 'changed');
fireEvent.press(screen.getByText('Save Changes'));
expect(screen.queryByText(/Connection failed/)).toBeNull();
});
});
describe('SettingsScreen dark mode switch', () => {
const darkSwitch = () => screen.UNSAFE_getByType(require('react-native').Switch);
it('is on for the dark preference and switches to light', () => {
useAppStore.setState({ theme: 'dark' });
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(true);
fireEvent(darkSwitch(), 'valueChange', false);
expect(state().theme).toBe('light');
});
it('is off for the light preference and switches to dark', () => {
useAppStore.setState({ theme: 'light' });
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(false);
fireEvent(darkSwitch(), 'valueChange', true);
expect(state().theme).toBe('dark');
});
it('follows the system scheme when the preference is "system"', () => {
mockSystemScheme = 'dark';
const first = render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(true);
first.unmount();
mockSystemScheme = 'light';
render(<SettingsScreen />);
expect(darkSwitch().props.value).toBe(false);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { createDebouncedStorage } from '../store/debouncedStorage';
const makeBase = () => ({
getItem: jest.fn(async (_name: string): Promise<string | null> => 'from-disk'),
setItem: jest.fn(async (_name: string, _value: string) => {}),
removeItem: jest.fn(async (_name: string) => {}),
});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('createDebouncedStorage', () => {
it('collapses a burst of writes into one trailing write with the latest value', () => {
const base = makeBase();
const storage = createDebouncedStorage(base, 400);
storage.setItem('k', '1');
jest.advanceTimersByTime(300);
storage.setItem('k', '2');
jest.advanceTimersByTime(300);
storage.setItem('k', '3');
expect(base.setItem).not.toHaveBeenCalled();
jest.advanceTimersByTime(400);
expect(base.setItem).toHaveBeenCalledTimes(1);
expect(base.setItem).toHaveBeenCalledWith('k', '3');
});
it('reads a pending value before it reaches the disk, and falls back to the base storage', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
await expect(storage.getItem('k')).resolves.toBe('from-disk');
storage.setItem('k', 'fresh');
await expect(storage.getItem('k')).resolves.toBe('fresh');
});
it('flush writes immediately and cancels the timer', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
storage.setItem('a', '1');
storage.setItem('b', '2');
await storage.flush();
expect(base.setItem.mock.calls).toEqual([['a', '1'], ['b', '2']]);
jest.advanceTimersByTime(1000);
expect(base.setItem).toHaveBeenCalledTimes(2);
});
it('flush with nothing pending does not write', async () => {
const base = makeBase();
await createDebouncedStorage(base).flush();
expect(base.setItem).not.toHaveBeenCalled();
});
it('removeItem drops a pending write and removes from the base storage', async () => {
const base = makeBase();
const storage = createDebouncedStorage(base);
storage.setItem('k', '1');
await storage.removeItem('k');
jest.advanceTimersByTime(1000);
expect(base.setItem).not.toHaveBeenCalled();
expect(base.removeItem).toHaveBeenCalledWith('k');
});
});
+390
View File
@@ -0,0 +1,390 @@
import { Platform } from 'react-native';
import {
buildBaseUrl,
chatCompletion,
chatCompletionStream,
getModels,
parseSSELines,
testConnection,
} from '../services/lmStudioApi';
import { ServerConfig } from '../services/types';
const config: ServerConfig = { host: '192.168.0.10', port: 1234 };
const fetchMock = jest.fn();
const originalFetch = global.fetch;
beforeEach(() => {
fetchMock.mockReset();
global.fetch = fetchMock as unknown as typeof fetch;
});
afterAll(() => {
global.fetch = originalFetch;
});
const sseLine = (content: string) => `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}`;
describe('buildBaseUrl', () => {
it('prepends http:// and appends port and /v1', () => {
expect(buildBaseUrl(config)).toBe('http://192.168.0.10:1234/v1');
});
it('keeps an explicit http or https scheme', () => {
expect(buildBaseUrl({ host: 'http://lan.local', port: 8080 })).toBe('http://lan.local:8080/v1');
expect(buildBaseUrl({ host: 'https://lan.local', port: 443 })).toBe('https://lan.local:443/v1');
});
it('strips trailing slashes from the host', () => {
expect(buildBaseUrl({ host: 'http://lan.local//', port: 1234 })).toBe('http://lan.local:1234/v1');
});
it('does not mistake a host that merely starts with "http" for a scheme', () => {
expect(buildBaseUrl({ host: 'httpbin.local', port: 1234 })).toBe('http://httpbin.local:1234/v1');
});
});
describe('parseSSELines', () => {
it('emits content of every complete data line and keeps the partial tail', () => {
const onChunk = jest.fn();
const onDone = jest.fn();
const result = parseSSELines(`${sseLine('Hel')}\n${sseLine('lo')}\ndata: {"cho`, onChunk, onDone);
expect(onChunk.mock.calls).toEqual([['Hel'], ['lo']]);
expect(result).toEqual({ remaining: 'data: {"cho', isDone: false });
expect(onDone).not.toHaveBeenCalled();
});
it('stops at [DONE] and calls onDone', () => {
const onChunk = jest.fn();
const onDone = jest.fn();
const result = parseSSELines(`${sseLine('a')}\ndata: [DONE]\n${sseLine('ignored')}\n`, onChunk, onDone);
expect(onChunk.mock.calls).toEqual([['a']]);
expect(onDone).toHaveBeenCalledTimes(1);
expect(result).toEqual({ remaining: '', isDone: true });
});
it('skips malformed JSON, empty deltas, comments and non-data lines', () => {
const onChunk = jest.fn();
parseSSELines(
[
'data: {not json}',
'data: {"choices":[{"delta":{}}]}',
'data: {"choices":[]}',
': keep-alive',
'event: message',
'',
sseLine('ok'),
'',
].join('\n'),
onChunk,
jest.fn(),
);
expect(onChunk.mock.calls).toEqual([['ok']]);
});
it('handles CRLF line endings and "data:" without a space', () => {
const onChunk = jest.fn();
const noSpace = `data:${JSON.stringify({ choices: [{ delta: { content: 'nospace' } }] })}`;
parseSSELines(`${sseLine('crlf')}\r\n${noSpace}\n`, onChunk, jest.fn());
expect(onChunk.mock.calls).toEqual([['crlf'], ['nospace']]);
});
});
describe('testConnection', () => {
it('sends the Authorization header only when an API key is set', async () => {
fetchMock.mockResolvedValue({ ok: true });
await testConnection({ ...config, apiKey: 'secret' });
await testConnection({ ...config, apiKey: '' });
expect(fetchMock.mock.calls[0][0]).toBe('http://192.168.0.10:1234/v1/models');
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Bearer secret' });
expect(fetchMock.mock.calls[1][1].headers).not.toHaveProperty('Authorization');
});
it('returns true on a 2xx response and false otherwise', async () => {
fetchMock.mockResolvedValueOnce({ ok: true });
await expect(testConnection(config)).resolves.toBe(true);
fetchMock.mockResolvedValueOnce({ ok: false });
await expect(testConnection(config)).resolves.toBe(false);
});
it('returns false when the network call throws', async () => {
fetchMock.mockRejectedValue(new TypeError('Network request failed'));
await expect(testConnection(config)).resolves.toBe(false);
});
it('aborts after 5 seconds and returns false', async () => {
jest.useFakeTimers();
try {
fetchMock.mockImplementation(
(_url: string, init: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => reject(new Error('aborted')));
}),
);
const pending = testConnection(config);
await jest.advanceTimersByTimeAsync(5000);
await expect(pending).resolves.toBe(false);
} finally {
jest.useRealTimers();
}
});
it('does not leave the timeout timer running after a failure', async () => {
jest.useFakeTimers();
try {
fetchMock.mockRejectedValue(new TypeError('Network request failed'));
await testConnection(config);
expect(jest.getTimerCount()).toBe(0);
} finally {
jest.useRealTimers();
}
});
});
describe('getModels', () => {
it('returns the data array', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ data: [{ id: 'm1' }] }) });
await expect(getModels(config)).resolves.toEqual([{ id: 'm1' }]);
});
it('falls back to an empty list when data is missing', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
await expect(getModels(config)).resolves.toEqual([]);
});
it('throws with the HTTP status when the response is not ok', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' });
await expect(getModels(config)).rejects.toThrow('Failed to fetch models: 401 Unauthorized');
});
});
describe('chatCompletion', () => {
it('posts a non-streaming request with defaults that options can override', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'x' }) });
await chatCompletion(config, 'model-a', [{ role: 'user', content: 'hi' }], { temperature: 0.1 });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://192.168.0.10:1234/v1/chat/completions');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({
model: 'model-a',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
temperature: 0.1,
max_tokens: 2048,
});
});
it('throws with the HTTP status when the response is not ok', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' });
await expect(chatCompletion(config, 'm', [])).rejects.toThrow('Chat completion failed: 500 Server Error');
});
});
describe('chatCompletionStream (web, fetch + ReadableStream)', () => {
const originalOS = Platform.OS;
const encoder = new TextEncoder();
beforeEach(() => {
Platform.OS = 'web';
});
afterEach(() => {
Platform.OS = originalOS;
});
const streamOf = (parts: string[]) =>
new ReadableStream<Uint8Array>({
start(controller) {
parts.forEach((p) => controller.enqueue(encoder.encode(p)));
controller.close();
},
});
// Starts a stream and resolves once either onDone or onError fired.
const run = () => {
const onChunk = jest.fn();
const onDone = jest.fn();
const onError = jest.fn();
const finished = new Promise<void>((resolve) => {
chatCompletionStream(
config,
'm',
[],
onChunk,
() => {
onDone();
resolve();
},
(e) => {
onError(e);
resolve();
},
);
});
return { onChunk, onDone, onError, finished };
};
it('streams chunks split across reads and calls onDone exactly once', async () => {
const whole = `${sseLine('Hi')}\n${sseLine(' there')}\ndata: [DONE]\n`;
fetchMock.mockResolvedValue({ ok: true, body: streamOf([whole.slice(0, 20), whole.slice(20)]) });
const { onChunk, onDone, onError, finished } = run();
await finished;
expect(onChunk.mock.calls).toEqual([['Hi'], [' there']]);
expect(onDone).toHaveBeenCalledTimes(1);
expect(onError).not.toHaveBeenCalled();
expect(JSON.parse(fetchMock.mock.calls[0][1].body).stream).toBe(true);
});
it('calls onDone when the stream ends without [DONE]', async () => {
fetchMock.mockResolvedValue({ ok: true, body: streamOf([`${sseLine('x')}\n`]) });
const { onChunk, onDone, finished } = run();
await finished;
expect(onChunk).toHaveBeenCalledWith('x');
expect(onDone).toHaveBeenCalledTimes(1);
});
it('reports an HTTP error through onError', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' });
const { onError, finished } = run();
await finished;
expect(onError.mock.calls[0][0].message).toBe('Stream failed: 404 Not Found');
});
it('turns a network failure into an actionable connection error', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
const { onError, finished } = run();
await finished;
expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1');
});
it('does not report an error when the request is aborted', async () => {
fetchMock.mockImplementation(
(_url: string, init: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => {
const err = new Error('aborted');
err.name = 'AbortError';
reject(err);
});
}),
);
const onError = jest.fn();
const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
handle.abort();
await new Promise((r) => setTimeout(r, 0));
expect(onError).not.toHaveBeenCalled();
});
});
describe('chatCompletionStream (native, XMLHttpRequest)', () => {
class FakeXHR {
static last: FakeXHR;
status = 200;
statusText = 'OK';
responseText = '';
headers: Record<string, string> = {};
aborted = false;
onprogress: (() => void) | null = null;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
ontimeout: (() => void) | null = null;
open = jest.fn();
send = jest.fn();
constructor() {
FakeXHR.last = this;
}
setRequestHeader(k: string, v: string) {
this.headers[k] = v;
}
abort() {
this.aborted = true;
}
}
const originalXHR = global.XMLHttpRequest;
beforeEach(() => {
global.XMLHttpRequest = FakeXHR as unknown as typeof XMLHttpRequest;
});
afterEach(() => {
global.XMLHttpRequest = originalXHR;
});
it('parses progress events incrementally and finishes once', () => {
const onChunk = jest.fn();
const onDone = jest.fn();
chatCompletionStream({ ...config, apiKey: 'k' }, 'm', [], onChunk, onDone, jest.fn());
const xhr = FakeXHR.last;
expect(xhr.open).toHaveBeenCalledWith('POST', 'http://192.168.0.10:1234/v1/chat/completions');
expect(xhr.headers.Authorization).toBe('Bearer k');
const full = `${sseLine('A')}\n${sseLine('B')}\n`;
xhr.responseText = full.slice(0, 30);
xhr.onprogress?.();
xhr.responseText = full;
xhr.onprogress?.();
xhr.responseText = `${full}data: [DONE]\n`;
xhr.onprogress?.();
xhr.onload?.();
expect(onChunk.mock.calls).toEqual([['A'], ['B']]);
expect(onDone).toHaveBeenCalledTimes(1);
});
it('flushes a final line that has no trailing newline on load', () => {
const onChunk = jest.fn();
chatCompletionStream(config, 'm', [], onChunk, jest.fn(), jest.fn());
const xhr = FakeXHR.last;
xhr.responseText = sseLine('tail');
xhr.onprogress?.();
xhr.onload?.();
expect(onChunk.mock.calls).toEqual([['tail']]);
});
it('reports an HTTP error status instead of finishing silently', () => {
const onDone = jest.fn();
const onError = jest.fn();
chatCompletionStream(config, 'm', [], jest.fn(), onDone, onError);
const xhr = FakeXHR.last;
xhr.status = 401;
xhr.statusText = 'Unauthorized';
xhr.responseText = '{"error":"bad key"}';
xhr.onprogress?.();
xhr.onload?.();
expect(onError.mock.calls[0][0].message).toBe('Stream failed: 401 Unauthorized');
expect(onDone).not.toHaveBeenCalled();
});
it('turns a network error into an actionable connection error', () => {
const onError = jest.fn();
chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
FakeXHR.last.status = 0;
FakeXHR.last.onerror?.();
expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1');
});
it('reports a timeout', () => {
const onError = jest.fn();
chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
FakeXHR.last.ontimeout?.();
expect(onError.mock.calls[0][0].message).toBe('Request timed out');
});
it('abort() aborts the request and swallows errors thrown by abort', () => {
const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), jest.fn());
const xhr = FakeXHR.last;
handle.abort();
expect(xhr.aborted).toBe(true);
xhr.abort = () => {
throw new Error('boom');
};
expect(() => handle.abort()).not.toThrow();
});
});
+188
View File
@@ -0,0 +1,188 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { appStorage } from '../store/debouncedStorage';
import { useAppStore } from '../store/useAppStore';
const state = () => useAppStore.getState();
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true);
});
describe('messages', () => {
it('addMessage appends with a generated id and timestamp, and returns the id', () => {
const id = state().addMessage({ role: 'user', content: 'hi' });
const [msg] = state().messages;
expect(msg.id).toBe(id);
expect(msg).toMatchObject({ role: 'user', content: 'hi' });
expect(typeof msg.timestamp).toBe('number');
});
it('generates distinct ids for messages created in the same millisecond', () => {
const ids = new Set(Array.from({ length: 50 }, () => state().addMessage({ role: 'user', content: 'x' })));
expect(ids.size).toBe(50);
});
it('appendToMessage and updateMessageContent only touch the target message', () => {
const a = state().addMessage({ role: 'assistant', content: 'He' });
const b = state().addMessage({ role: 'assistant', content: 'other' });
state().appendToMessage(a, 'llo');
expect(state().messages.map((m) => m.content)).toEqual(['Hello', 'other']);
state().updateMessageContent(b, 'replaced');
expect(state().messages.map((m) => m.content)).toEqual(['Hello', 'replaced']);
});
it('editMessage replaces the content and marks it as edited', () => {
const id = state().addMessage({ role: 'user', content: 'old' });
state().editMessage(id, 'new');
expect(state().messages[0]).toMatchObject({ content: 'new', isEdited: true });
});
it('deleteMessage removes only the given message', () => {
const a = state().addMessage({ role: 'user', content: 'a' });
state().addMessage({ role: 'assistant', content: 'b' });
state().deleteMessage(a);
expect(state().messages.map((m) => m.content)).toEqual(['b']);
});
describe('deleteMessagesAfter', () => {
it('keeps the given message and drops everything after it', () => {
const a = state().addMessage({ role: 'user', content: 'a' });
state().addMessage({ role: 'assistant', content: 'b' });
state().addMessage({ role: 'user', content: 'c' });
state().deleteMessagesAfter(a);
expect(state().messages.map((m) => m.content)).toEqual(['a']);
});
it('does nothing when the id is unknown', () => {
state().addMessage({ role: 'user', content: 'a' });
state().deleteMessagesAfter('missing');
expect(state().messages).toHaveLength(1);
});
});
it('clearChat empties the conversation and forgets the current session', () => {
state().addMessage({ role: 'user', content: 'a' });
state().setCurrentChatId('abc');
state().clearChat();
expect(state().messages).toEqual([]);
expect(state().currentChatId).toBeNull();
});
});
describe('chat sessions', () => {
it('saveCurrentChat ignores an empty conversation', () => {
state().saveCurrentChat();
expect(state().chatSessions).toEqual([]);
expect(state().currentChatId).toBeNull();
});
it('creates a session titled by the first user message and makes it current', () => {
state().addMessage({ role: 'assistant', content: 'welcome' });
state().addMessage({ role: 'user', content: 'first question' });
state().saveCurrentChat();
expect(state().chatSessions).toHaveLength(1);
const [session] = state().chatSessions;
expect(session.title).toBe('first question');
expect(session.messages).toHaveLength(2);
expect(state().currentChatId).toBe(session.id);
});
it('truncates long titles to 40 characters plus an ellipsis', () => {
state().addMessage({ role: 'user', content: 'x'.repeat(60) });
state().saveCurrentChat();
expect(state().chatSessions[0].title).toBe(`${'x'.repeat(40)}...`);
});
it('falls back to "New Chat" when there is no user message', () => {
state().addMessage({ role: 'assistant', content: 'hello' });
state().saveCurrentChat();
expect(state().chatSessions[0].title).toBe('New Chat');
});
it('updates the existing session instead of creating another one', () => {
state().addMessage({ role: 'user', content: 'q' });
state().saveCurrentChat();
const firstId = state().currentChatId;
state().addMessage({ role: 'assistant', content: 'a' });
state().saveCurrentChat();
expect(state().chatSessions).toHaveLength(1);
expect(state().chatSessions[0].id).toBe(firstId);
expect(state().chatSessions[0].messages).toHaveLength(2);
});
it('only rewrites the session that matches currentChatId', () => {
useAppStore.setState({
chatSessions: [
{ id: '1', title: 'one', updatedAt: 1, messages: [] },
{ id: '2', title: 'two', updatedAt: 2, messages: [] },
],
currentChatId: '2',
messages: [{ id: 'm', role: 'user', content: 'changed', timestamp: 3 }],
});
state().saveCurrentChat();
expect(state().chatSessions[0]).toMatchObject({ id: '1', title: 'one' });
expect(state().chatSessions[1]).toMatchObject({ id: '2', title: 'changed' });
});
it('loadChat restores the messages of a session, and ignores unknown ids', () => {
const messages = [{ id: 'm', role: 'user' as const, content: 'saved', timestamp: 1 }];
useAppStore.setState({ chatSessions: [{ id: 's', title: 't', updatedAt: 1, messages }] });
state().loadChat('missing');
expect(state().messages).toEqual([]);
state().loadChat('s');
expect(state().messages).toEqual(messages);
expect(state().currentChatId).toBe('s');
});
it('deleteChatSession resets the open conversation only when it deletes the current one', () => {
const session = (id: string) => ({ id, title: id, updatedAt: 1, messages: [] });
useAppStore.setState({
chatSessions: [session('a'), session('b')],
currentChatId: 'a',
messages: [{ id: 'm', role: 'user', content: 'x', timestamp: 1 }],
});
state().deleteChatSession('b');
expect(state().chatSessions.map((s) => s.id)).toEqual(['a']);
expect(state().messages).toHaveLength(1);
state().deleteChatSession('a');
expect(state().chatSessions).toEqual([]);
expect(state().messages).toEqual([]);
expect(state().currentChatId).toBeNull();
});
});
describe('persistence', () => {
it('persists settings, the selected model and chats but not transient connection or streaming state', async () => {
useAppStore.setState({
theme: 'dark',
serverConfig: { host: 'lan.local', port: 4321, apiKey: 'k' },
isConnected: true,
isStreaming: true,
activeModelId: 'model',
availableModels: [{ id: 'model', object: 'model', owned_by: 'x' }],
editingMessageId: 'e',
});
state().addMessage({ role: 'user', content: 'persist me' });
await appStorage.flush();
const raw = await AsyncStorage.getItem('localaiapp-storage');
const persisted = JSON.parse(raw as string).state;
expect(Object.keys(persisted).sort()).toEqual(
['activeModelId', 'chatSessions', 'currentChatId', 'messages', 'serverConfig', 'theme'],
);
expect(persisted.serverConfig).toEqual({ host: 'lan.local', port: 4321, apiKey: 'k' });
expect(persisted.activeModelId).toBe('model');
expect(persisted.messages[0].content).toBe('persist me');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { act, renderHook } from '@testing-library/react-native';
import { useAutoConnect } from '../hooks/useAutoConnect';
import * as api from '../services/lmStudioApi';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
const mockApi = api as jest.Mocked<typeof api>;
beforeEach(() => {
jest.resetAllMocks();
useAppStore.setState(useAppStore.getInitialState(), true);
});
describe('useAutoConnect', () => {
it('loads the models once on mount and marks the server connected', async () => {
mockApi.getModels.mockResolvedValue([{ id: 'm1', object: 'model', owned_by: 'x' }]);
renderHook(() => useAutoConnect());
await act(async () => {});
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
expect(useAppStore.getState().isConnected).toBe(true);
expect(useAppStore.getState().activeModelId).toBe('m1');
});
it('leaves the app offline when the server does not answer', async () => {
mockApi.getModels.mockRejectedValue(new Error('down'));
renderHook(() => useAutoConnect());
await act(async () => {});
expect(useAppStore.getState().isConnected).toBe(false);
});
it('does not check again on re-render', async () => {
mockApi.getModels.mockResolvedValue([]);
const { rerender } = renderHook(() => useAutoConnect());
await act(async () => {});
rerender({});
await act(async () => {});
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
});
});
+246
View File
@@ -0,0 +1,246 @@
import { act, renderHook } from '@testing-library/react-native';
import { useLMStudio } from '../hooks/useLMStudio';
import * as api from '../services/lmStudioApi';
import { useAppStore } from '../store/useAppStore';
jest.mock('../services/lmStudioApi');
const mockApi = api as jest.Mocked<typeof api>;
const state = () => useAppStore.getState();
type StreamArgs = Parameters<typeof api.chatCompletionStream>;
const lastStreamCall = () => mockApi.chatCompletionStream.mock.calls.at(-1) as StreamArgs;
beforeEach(() => {
jest.resetAllMocks();
useAppStore.setState(useAppStore.getInitialState(), true);
useAppStore.setState({ activeModelId: 'model-a' });
mockApi.chatCompletionStream.mockReturnValue({ abort: jest.fn() });
});
describe('checkConnection', () => {
it('stores the result of the connection test', async () => {
mockApi.testConnection.mockResolvedValue(true);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await expect(result.current.checkConnection()).resolves.toBe(true);
});
expect(state().isConnected).toBe(true);
});
});
describe('fetchModels', () => {
const models = [
{ id: 'first', object: 'model', owned_by: 'x' },
{ id: 'second', object: 'model', owned_by: 'x' },
];
it('stores the models, marks the server connected and selects the first model if none is active', async () => {
useAppStore.setState({ activeModelId: '' });
mockApi.getModels.mockResolvedValue(models);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.fetchModels();
});
expect(state().availableModels).toEqual(models);
expect(state().isConnected).toBe(true);
expect(state().activeModelId).toBe('first');
});
it('keeps the model that was already selected', async () => {
mockApi.getModels.mockResolvedValue(models);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.fetchModels();
});
expect(state().activeModelId).toBe('model-a');
});
it('does not select anything when the server has no models', async () => {
useAppStore.setState({ activeModelId: '' });
mockApi.getModels.mockResolvedValue([]);
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.fetchModels();
});
expect(state().activeModelId).toBe('');
});
it('marks the server offline and clears the list when the request fails', async () => {
useAppStore.setState({ isConnected: true, availableModels: models });
mockApi.getModels.mockRejectedValue(new Error('down'));
const { result } = renderHook(() => useLMStudio());
let returned: unknown;
await act(async () => {
returned = await result.current.fetchModels();
});
expect(returned).toEqual([]);
expect(state().isConnected).toBe(false);
expect(state().availableModels).toEqual([]);
});
});
describe('sendMessage', () => {
it('streams an answer for the conversation, prefixed by the system prompt', async () => {
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
const [, modelId, apiMessages, onChunk, onDone] = lastStreamCall();
expect(modelId).toBe('model-a');
expect(apiMessages[0].role).toBe('system');
expect(apiMessages.slice(1)).toEqual([{ role: 'user', content: 'hello' }]);
expect(state().isStreaming).toBe(true);
act(() => {
onChunk('Hi');
onChunk(' there');
onDone();
});
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual(['user:hello', 'assistant:Hi there']);
expect(state().isStreaming).toBe(false);
expect(state().chatSessions).toHaveLength(1);
});
it('asks for a model instead of calling the server when none is selected', async () => {
useAppStore.setState({ activeModelId: '' });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
expect(state().messages[1].content).toContain('No model selected');
expect(state().isStreaming).toBe(false);
});
it('ignores new messages while an answer is streaming', async () => {
useAppStore.setState({ isStreaming: true });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('again');
});
expect(state().messages).toEqual([]);
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
});
it('writes the error into the assistant message and stops streaming when the stream fails', async () => {
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
const onError = lastStreamCall()[5];
act(() => {
onError(new Error('Could not reach the server'));
});
expect(state().messages[1].content).toContain('Error: Could not reach the server');
expect(state().isStreaming).toBe(false);
expect(state().chatSessions).toHaveLength(1);
});
it('reports a synchronous failure to start the stream', async () => {
mockApi.chatCompletionStream.mockImplementation(() => {
throw new Error('boom');
});
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
expect(state().messages[1].content).toContain('Connection failed: boom');
expect(state().isStreaming).toBe(false);
});
});
describe('regenerateResponse', () => {
it('drops everything after the last user message and asks again', async () => {
state().addMessage({ role: 'user', content: 'q1' });
state().addMessage({ role: 'assistant', content: 'a1' });
state().addMessage({ role: 'user', content: 'q2' });
state().addMessage({ role: 'assistant', content: 'a2' });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.regenerateResponse();
});
const apiMessages = lastStreamCall()[2];
expect(apiMessages.slice(1).map((m) => m.content)).toEqual(['q1', 'a1', 'q2']);
expect(state().messages.map((m) => m.content)).toEqual(['q1', 'a1', 'q2', '']);
expect(state().isStreaming).toBe(true);
});
it('keeps the current answer and asks for a model when none is selected', async () => {
state().addMessage({ role: 'user', content: 'q' });
state().addMessage({ role: 'assistant', content: 'a' });
useAppStore.setState({ activeModelId: '' });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.regenerateResponse();
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
expect(state().messages.map((m) => m.content)).toEqual([
'q',
'a',
expect.stringContaining('No model selected'),
]);
});
it('does nothing without a user message or while streaming', async () => {
const { result, rerender } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.regenerateResponse();
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
expect(state().messages).toEqual([]);
act(() => {
state().addMessage({ role: 'user', content: 'q' });
});
act(() => {
useAppStore.setState({ activeModelId: 'model-a', isStreaming: true });
});
rerender({});
await act(async () => {
await result.current.regenerateResponse();
});
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
});
});
describe('stopStreaming', () => {
it('aborts the request, stops streaming and keeps the partial answer in History', async () => {
const abort = jest.fn();
mockApi.chatCompletionStream.mockReturnValue({ abort });
const { result } = renderHook(() => useLMStudio());
await act(async () => {
await result.current.sendMessage('hello');
});
act(() => {
lastStreamCall()[3]('partial');
});
act(() => {
result.current.stopStreaming();
});
expect(abort).toHaveBeenCalledTimes(1);
expect(state().isStreaming).toBe(false);
expect(state().chatSessions[0].messages.at(-1)?.content).toBe('partial');
});
it('is a no-op when nothing is streaming', () => {
const { result } = renderHook(() => useLMStudio());
act(() => {
result.current.stopStreaming();
});
expect(state().chatSessions).toEqual([]);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { ChatMessage } from '../store/useAppStore';
import { findLastAssistantId, shouldShowTypingIndicator } from '../utils/chatSelectors';
import { buildServerConfig, isConfigChanged } from '../utils/serverConfig';
import { isDarkTheme } from '../utils/theme';
const msg = (id: string, role: ChatMessage['role'], content = 'x'): ChatMessage => ({
id,
role,
content,
timestamp: 0,
});
describe('findLastAssistantId', () => {
it('returns the most recent assistant message', () => {
expect(findLastAssistantId([msg('1', 'assistant'), msg('2', 'user'), msg('3', 'assistant')])).toBe('3');
});
it('returns null when there is no assistant message', () => {
expect(findLastAssistantId([msg('1', 'user')])).toBeNull();
expect(findLastAssistantId([])).toBeNull();
});
});
describe('shouldShowTypingIndicator', () => {
it('is true only while streaming an assistant message with no text yet', () => {
expect(shouldShowTypingIndicator([msg('1', 'user'), msg('2', 'assistant', '')], true)).toBe(true);
});
it('is false when not streaming, when text arrived, when the last message is the user, or when empty', () => {
expect(shouldShowTypingIndicator([msg('2', 'assistant', '')], false)).toBe(false);
expect(shouldShowTypingIndicator([msg('2', 'assistant', 'hi')], true)).toBe(false);
expect(shouldShowTypingIndicator([msg('1', 'user', '')], true)).toBe(false);
expect(shouldShowTypingIndicator([], true)).toBe(false);
});
});
describe('buildServerConfig', () => {
it('trims the host and API key and parses the port', () => {
expect(buildServerConfig({ host: ' 10.0.0.1 ', port: '8080', apiKey: ' k ' })).toEqual({
host: '10.0.0.1',
port: 8080,
apiKey: 'k',
});
});
it('uses the defaults for blank host, invalid port and blank key', () => {
expect(buildServerConfig({ host: '', port: 'abc', apiKey: ' ' })).toEqual({
host: 'localhost',
port: 1234,
apiKey: undefined,
});
});
});
describe('isConfigChanged', () => {
const saved = { host: 'lan', port: 1234, apiKey: 'k' };
it('is false when the form matches the saved config', () => {
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: 'k' }, saved)).toBe(false);
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, { host: 'lan', port: 1234 })).toBe(false);
});
it('is true when any single field differs', () => {
expect(isConfigChanged({ host: 'other', port: '1234', apiKey: 'k' }, saved)).toBe(true);
expect(isConfigChanged({ host: 'lan', port: '99', apiKey: 'k' }, saved)).toBe(true);
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, saved)).toBe(true);
});
});
describe('isDarkTheme', () => {
it('honours an explicit preference and falls back to the system scheme', () => {
expect(isDarkTheme('dark', 'light')).toBe(true);
expect(isDarkTheme('light', 'dark')).toBe(false);
expect(isDarkTheme('system', 'dark')).toBe(true);
expect(isDarkTheme('system', 'light')).toBe(false);
});
});
+14 -8
View File
@@ -1,19 +1,17 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { StyleSheet, Text, View } from 'react-native';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<ThemedView style={styles.container}>
<ThemedText type="title">This screen doesn't exist.</ThemedText>
<View style={styles.container}>
<Text style={styles.title}>This screen does not exist.</Text>
<Link href="/" style={styles.link}>
<ThemedText type="link">Go to home screen!</ThemedText>
<Text style={styles.linkText}>Go to home screen</Text>
</Link>
</ThemedView>
</View>
</>
);
}
@@ -25,8 +23,16 @@ 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,4 +1,5 @@
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';
@@ -10,6 +11,7 @@ export default function ChatLayout() {
const router = useRouter();
const clearChat = useAppStore(state => state.clearChat);
const colors = useThemeColor();
useAutoConnect();
const isLargeScreen = width > 768;
const containerStyle = [
@@ -36,6 +38,8 @@ 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}
>
@@ -43,6 +47,8 @@ export default function ChatLayout() {
</TouchableOpacity>
<TouchableOpacity
onPress={clearChat}
accessibilityRole="button"
accessibilityLabel="New chat"
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7}
>
@@ -53,6 +59,8 @@ export default function ChatLayout() {
headerRight: () => (
<TouchableOpacity
onPress={() => router.push('/settings')}
accessibilityRole="button"
accessibilityLabel="Settings"
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7}
>
+10 -1
View File
@@ -20,7 +20,12 @@ 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}>
<TouchableOpacity
onPress={() => router.back()}
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ArrowLeft size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>Chat History</Text>
@@ -43,6 +48,8 @@ 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}>
@@ -55,6 +62,8 @@ 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>
+17 -193
View File
@@ -1,20 +1,12 @@
import { ChatInput } from '@/components/chat/ChatInput';
import { MessageBubble } from '@/components/chat/MessageBubble';
import { TypingIndicator } from '@/components/chat/TypingIndicator';
import { EmptyState } from '@/components/chat/EmptyState';
import { MessageList } from '@/components/chat/MessageList';
import { OfflineBanner } from '@/components/chat/OfflineBanner';
import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
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',
];
import React, { useCallback } from 'react';
import { KeyboardAvoidingView, Platform, StyleSheet } from 'react-native';
export default function ChatScreen() {
const colors = useThemeColor(); // Dynamic theme
@@ -25,18 +17,6 @@ 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
@@ -48,128 +28,35 @@ export default function ChatScreen() {
editMessage(editingMessageId, text);
deleteMessagesAfter(editingMessageId);
setEditingMessageId(null);
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);
// The edited message is now the last one, so regenerating answers it.
regenerateResponse();
} else {
sendMessage(text);
isAutoScrollEnabled.current = true; // explicitly re-enable on manual send
}
}, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, sendMessage]);
}, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, regenerateResponse, 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 && (
<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>
)}
{!isConnected && <OfflineBanner />}
{messages.length === 0 ? (
<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>
<EmptyState onSelectPrompt={sendMessage} />
) : (
<>
<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}
/>
</>
<MessageList
messages={messages}
isStreaming={isStreaming}
onEdit={handleEdit}
onDelete={handleDelete}
onRegenerate={regenerateResponse}
/>
)}
<ChatInput
@@ -187,67 +74,4 @@ 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',
},
});
+8 -338
View File
@@ -1,76 +1,14 @@
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
import { useLMStudio } from '@/hooks/useLMStudio';
import { AboutCard } from '@/components/settings/AboutCard';
import { AppearanceCard } from '@/components/settings/AppearanceCard';
import { ServerConnectionCard } from '@/components/settings/ServerConnectionCard';
import { useThemeColor } from '@/hooks/useThemeColor';
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';
import React from 'react';
import { ScrollView, StyleSheet, useWindowDimensions } 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
@@ -80,151 +18,9 @@ export default function SettingsScreen() {
isLargeScreen && styles.desktopContent,
]}
>
{/* 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>
<ServerConnectionCard />
<AppearanceCard />
<AboutCard />
</ScrollView>
);
}
@@ -241,130 +37,4 @@ 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
@@ -0,0 +1,16 @@
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,
},
],
],
};
};
+13 -28
View File
@@ -1,7 +1,6 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import * as Haptics from 'expo-haptics';
import { Globe, Send, Square, X } from 'lucide-react-native';
import { Send, Square, X } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import {
Platform,
@@ -23,8 +22,6 @@ 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(() => {
@@ -68,7 +65,12 @@ 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}>
<TouchableOpacity
onPress={handleCancelEdit}
style={styles.editCancelBtn}
accessibilityRole="button"
accessibilityLabel="Cancel editing"
>
<X size={16} color={colors.textMuted} />
</TouchableOpacity>
</View>
@@ -80,23 +82,10 @@ 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}
@@ -111,6 +100,8 @@ 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>
@@ -123,6 +114,9 @@ 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>
@@ -181,15 +175,6 @@ 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
@@ -0,0 +1,77 @@
import { Check, Copy } from 'lucide-react-native';
import React from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
interface CodeBlockProps {
language: string;
content: string;
copied: boolean;
onCopy: (text: string) => void;
}
export function CodeBlock({ language, content, copied, onCopy }: CodeBlockProps) {
const CopyIcon = copied ? Check : Copy;
return (
<View style={styles.codeBlockWrapper}>
<View style={styles.codeBlockHeader}>
<Text style={styles.codeBlockLanguage}>{language}</Text>
<TouchableOpacity
onPress={() => onCopy(content)}
style={styles.copyButton}
accessibilityRole="button"
>
<CopyIcon size={14} color="#A0A0A0" />
<Text style={styles.copyText}>{copied ? 'Copied!' : 'Copy'}</Text>
</TouchableOpacity>
</View>
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
codeBlockWrapper: {
backgroundColor: '#1E1E1E',
borderRadius: 8,
marginVertical: 8,
overflow: 'hidden',
width: '100%',
},
codeBlockHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#2D2D2D',
paddingHorizontal: 12,
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#404040',
},
codeBlockLanguage: {
color: '#A0A0A0',
fontSize: 12,
fontFamily: 'monospace',
textTransform: 'lowercase',
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
padding: 4,
},
copyText: {
color: '#A0A0A0',
fontSize: 12,
},
codeScrollView: {
padding: 12,
},
codeText: {
color: '#D4D4D4',
fontSize: 13,
fontFamily: 'monospace',
},
});
+99
View File
@@ -0,0 +1,99 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { MessageSquare, Sparkles } from 'lucide-react-native';
import React from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
const PROMPT_SUGGESTIONS = [
'Explain recursion in simple terms',
'Write a short poem about coding',
'What are the best practices for React Native?',
'Help me debug a JavaScript error',
];
interface EmptyStateProps {
onSelectPrompt: (prompt: string) => void;
}
export function EmptyState({ onSelectPrompt }: EmptyStateProps) {
const colors = useThemeColor();
return (
<View style={[styles.emptyState, { backgroundColor: colors.backgroundColor }]}>
<View style={[styles.emptyIconWrap, { backgroundColor: colors.cardBackground, shadowColor: colors.shadow }]}>
<MessageSquare size={40} color={colors.textMuted} />
</View>
<Text style={[styles.emptyTitle, { color: colors.text }]}>Start a conversation</Text>
<Text style={[styles.emptySubtitle, { color: colors.textMuted }]}>
Send a message to begin chatting with your local AI model.
</Text>
<View style={styles.suggestionsWrap}>
{PROMPT_SUGGESTIONS.map((prompt) => (
<TouchableOpacity
key={prompt}
style={[styles.suggestionChip, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}
onPress={() => onSelectPrompt(prompt)}
activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel={prompt}
>
<Sparkles size={14} color={colors.textMuted} />
<Text style={[styles.suggestionText, { color: colors.text }]} numberOfLines={1}>{prompt}</Text>
</TouchableOpacity>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
paddingBottom: 80,
},
emptyIconWrap: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 2,
},
emptyTitle: {
fontSize: 22,
fontWeight: '700',
marginBottom: 8,
},
emptySubtitle: {
fontSize: 15,
textAlign: 'center',
lineHeight: 22,
marginBottom: 32,
},
suggestionsWrap: {
width: '100%',
maxWidth: 400,
gap: 12,
},
suggestionChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
borderWidth: 1,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 14,
},
suggestionText: {
fontSize: 15,
flex: 1,
fontWeight: '500',
},
});
+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}>
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Copy message">
<Copy size={16} color={colors.textMuted} />
</TouchableOpacity>
{isUser && onEdit && (
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6}>
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Edit message">
<Pencil size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
{!isUser && onRegenerate && (
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6}>
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Regenerate response">
<RefreshCw size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
{onDelete && (
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6}>
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Delete message">
<Trash2 size={16} color={colors.textMuted} />
</TouchableOpacity>
)}
+63 -342
View File
@@ -1,12 +1,10 @@
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 { 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 { Bot, User } from 'lucide-react-native';
import React, { useState } from 'react';
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
import { MessageActions } from './MessageActions';
import { ThinkingBlock } from './ThinkingBlock';
import { MessageContent } from './MessageContent';
interface MessageBubbleProps {
message: ChatMessage;
@@ -17,6 +15,58 @@ 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,
@@ -25,285 +75,24 @@ 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 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>
)}
</>
);
};
const showingStreaming = !!isStreaming && !!isLastAssistant;
const actionsVisible = (!isUser || showActions) && message.content.length > 0;
return (
<View
onTouchStart={() => isUser && setShowActions(true)}
style={styles.outerWrap}
>
<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>
)}
<BubbleRow message={message} showingStreaming={showingStreaming} />
<BubbleMeta message={message} />
<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 && (
{actionsVisible && (
<MessageActions
role={message.role}
content={showingStreaming ? displayedContent : message.content}
content={message.content}
onEdit={isUser ? onEdit : undefined}
onRegenerate={!isUser && isLastAssistant ? onRegenerate : undefined}
onDelete={onDelete}
@@ -332,15 +121,7 @@ const styles = StyleSheet.create({
containerAssistant: {
justifyContent: 'flex-start',
},
avatarAssistant: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 2,
},
avatarUser: {
avatar: {
width: 32,
height: 32,
borderRadius: 16,
@@ -361,10 +142,6 @@ const styles = StyleSheet.create({
bubbleAssistant: {
borderBottomLeftRadius: 6,
},
text: {
fontSize: 15,
lineHeight: 22,
},
metaRow: {
flexDirection: 'row',
paddingHorizontal: 16,
@@ -382,60 +159,4 @@ 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
@@ -0,0 +1,73 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { ChatMessage } from '@/store/useAppStore';
import Markdown from '@ronradtke/react-native-markdown-display';
import * as Clipboard from 'expo-clipboard';
import React, { useMemo, useState } from 'react';
import { StyleSheet, Text } from 'react-native';
import { CodeBlock } from './CodeBlock';
import { createMarkdownStyles } from './markdownStyles';
interface MessageContentProps {
message: ChatMessage;
/** True while this message is the answer currently being streamed. */
showingStreaming: boolean;
}
export function MessageContent({ message, showingStreaming }: MessageContentProps) {
const colors = useThemeColor();
const [copiedText, setCopiedText] = useState<string | null>(null);
const handleCopy = async (text: string) => {
await Clipboard.setStringAsync(text);
setCopiedText(text);
setTimeout(() => setCopiedText(null), 2000);
};
const markdownStyles = useMemo(() => createMarkdownStyles(colors), [colors]);
const markdownRules = useMemo(() => ({
fence: (node: any) => (
<CodeBlock
key={node.key}
language={node.sourceInfo || 'code'}
content={node.content || ''}
copied={copiedText === (node.content || '')}
onCopy={handleCopy}
/>
),
code_block: (node: any) => (
<CodeBlock
key={node.key}
language="code"
content={node.content || ''}
copied={copiedText === (node.content || '')}
onCopy={handleCopy}
/>
),
}), [copiedText]);
if (message.role === 'user') {
return (
<Text style={[styles.text, { color: colors.messageUserText }]}>
{message.content}
</Text>
);
}
if (!message.content && showingStreaming) {
return null; // TypingIndicator handled externally
}
return (
<Markdown style={markdownStyles} rules={markdownRules}>
{message.content}
</Markdown>
);
}
const styles = StyleSheet.create({
text: {
fontSize: 15,
lineHeight: 22,
},
});
+94
View File
@@ -0,0 +1,94 @@
import { MessageBubble } from '@/components/chat/MessageBubble';
import { TypingIndicator } from '@/components/chat/TypingIndicator';
import { ChatMessage } from '@/store/useAppStore';
import { findLastAssistantId, shouldShowTypingIndicator } from '@/utils/chatSelectors';
import React, { useEffect, useRef } from 'react';
import { FlatList, StyleSheet } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
interface MessageListProps {
messages: ChatMessage[];
isStreaming: boolean;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onRegenerate: () => void;
}
interface MessageRowProps {
message: ChatMessage;
delayed: boolean;
isStreaming: boolean;
isLastAssistant: boolean;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onRegenerate: () => void;
}
// Memoized so that a streamed token only re-renders (and re-parses the Markdown of)
// the message that changed, not the whole conversation.
const MessageRow = React.memo(function MessageRow({
message,
delayed,
isStreaming,
isLastAssistant,
onEdit,
onDelete,
onRegenerate,
}: MessageRowProps) {
return (
<Animated.View entering={FadeInDown.duration(300).delay(delayed ? 100 : 0)}>
<MessageBubble
message={message}
onEdit={() => onEdit(message.id)}
onRegenerate={onRegenerate}
onDelete={() => onDelete(message.id)}
isStreaming={isStreaming}
isLastAssistant={isLastAssistant}
/>
</Animated.View>
);
});
export function MessageList({ messages, isStreaming, onEdit, onDelete, onRegenerate }: MessageListProps) {
const flatListRef = useRef<FlatList>(null);
const lastAssistantId = findLastAssistantId(messages);
const lastContentLength = messages[messages.length - 1]?.content?.length;
useEffect(() => {
if (messages.length > 0 && flatListRef.current) {
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: !isStreaming });
}, 100);
}
}, [messages.length, lastContentLength, isStreaming]);
return (
<>
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.id}
renderItem={({ item, index }) => (
<MessageRow
message={item}
delayed={index > messages.length - 4}
isStreaming={isStreaming}
isLastAssistant={item.id === lastAssistantId}
onEdit={onEdit}
onDelete={onDelete}
onRegenerate={onRegenerate}
/>
)}
contentContainerStyle={styles.listContent}
/>
{shouldShowTypingIndicator(messages, isStreaming) && <TypingIndicator />}
</>
);
}
const styles = StyleSheet.create({
listContent: {
paddingTop: 16,
paddingBottom: 24,
},
});
+7
View File
@@ -42,6 +42,8 @@ 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}>
@@ -70,6 +72,8 @@ 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} />
@@ -108,6 +112,9 @@ 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
@@ -0,0 +1,31 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
export function OfflineBanner() {
const colors = useThemeColor();
return (
<View
accessibilityRole="alert"
style={[styles.banner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.bannerBg }]}
>
<Text style={[styles.bannerText, { color: colors.bannerText }]}>
⚠️ Server offline. Check Settings → Server Connection.
</Text>
</View>
);
}
const styles = StyleSheet.create({
banner: {
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: 1,
},
bannerText: {
fontSize: 13,
textAlign: 'center',
fontWeight: '500',
},
});
-127
View File
@@ -1,127 +0,0 @@
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
@@ -0,0 +1,99 @@
import { useThemeColor } from '@/hooks/useThemeColor';
type ThemeColors = ReturnType<typeof useThemeColor>;
/** Style map for the Markdown renderer, derived from the active theme colours. */
export function createMarkdownStyles(colors: ThemeColors) {
return {
body: {
fontSize: 15,
lineHeight: 22,
color: colors.messageAssistantText,
},
strong: {
fontWeight: '700',
color: colors.text,
},
em: {
fontStyle: 'italic',
},
code_inline: {
backgroundColor: colors.backgroundColor,
color: colors.text,
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 4,
fontSize: 13,
fontFamily: 'monospace',
},
fence: {
backgroundColor: colors.inputBg,
borderColor: colors.border,
borderWidth: 1,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
overflow: 'hidden',
},
code_block: {
backgroundColor: colors.inputBg,
color: colors.text,
padding: 12,
borderRadius: 8,
fontSize: 13,
fontFamily: 'monospace',
marginVertical: 8,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: colors.border,
paddingLeft: 12,
marginVertical: 4,
color: colors.textMuted,
},
bullet_list: {
marginVertical: 4,
},
ordered_list: {
marginVertical: 4,
},
list_item: {
marginVertical: 2,
color: colors.messageAssistantText,
},
heading1: {
fontSize: 22,
fontWeight: '700',
marginVertical: 8,
color: colors.text,
},
heading2: {
fontSize: 19,
fontWeight: '700',
marginVertical: 6,
color: colors.text,
},
heading3: {
fontSize: 17,
fontWeight: '600',
marginVertical: 4,
color: colors.text,
},
link: {
color: '#3B82F6',
textDecorationLine: 'underline',
},
hr: {
backgroundColor: colors.border,
height: 1,
marginVertical: 12,
},
paragraph: {
marginVertical: 2,
},
// The Markdown renderer's style types do not accept every RN style key literal.
} as any;
}
-25
View File
@@ -1,25 +0,0 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}
-18
View File
@@ -1,18 +0,0 @@
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}
-19
View File
@@ -1,19 +0,0 @@
import Animated from 'react-native-reanimated';
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
'50%': { transform: [{ rotate: '25deg' }] },
},
animationIterationCount: 4,
animationDuration: '300ms',
}}>
👋
</Animated.Text>
);
}
-79
View File
@@ -1,79 +0,0 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/components/themed-view';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useThemeColor } from '@/hooks/use-theme-color';
const HEADER_HEIGHT = 250;
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const backgroundColor = useThemeColor({}, 'background');
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollOffset(scrollRef);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
return (
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16}>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});
+38
View File
@@ -0,0 +1,38 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { Info, MonitorCog } from 'lucide-react-native';
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { SettingsSection } from './SettingsSection';
export function AboutCard() {
const colors = useThemeColor();
return (
<SettingsSection title="ABOUT">
<View style={styles.rowGroup}>
<Info size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.text }]}>LocalMind - Cross Platform Version</Text>
</View>
<View style={[styles.rowGroup, { marginTop: 8 }]}>
<MonitorCog size={18} color={colors.textMuted} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: colors.textMuted, fontSize: 13 }]}>
Connected via OpenAPI `/v1/chat/completions` specs
</Text>
</View>
</SettingsSection>
);
}
const styles = StyleSheet.create({
rowGroup: {
flexDirection: 'row',
alignItems: 'center',
},
infoIcon: {
marginRight: 10,
},
infoText: {
fontSize: 15,
lineHeight: 22,
},
});
+62
View File
@@ -0,0 +1,62 @@
import { useColorScheme } from '@/hooks/useColorScheme';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import { isDarkTheme } from '@/utils/theme';
import { Moon } from 'lucide-react-native';
import React from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import { SettingsSection } from './SettingsSection';
export function AppearanceCard() {
const colors = useThemeColor();
const systemScheme = useColorScheme();
const themePreference = useAppStore(state => state.theme);
const setTheme = useAppStore(state => state.setTheme);
const isDarkMode = isDarkTheme(themePreference, systemScheme);
const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark');
return (
<SettingsSection title="APPEARANCE">
<View style={styles.row}>
<View style={styles.rowIconWrap}>
<Moon size={20} color={colors.text} />
</View>
<View style={styles.rowText}>
<Text style={[styles.rowTitle, { color: colors.text }]}>Dark Mode</Text>
<Text style={[styles.rowSubtitle, { color: colors.textMuted }]}>Toggle sleek dark theme</Text>
</View>
<Switch
value={isDarkMode}
onValueChange={toggleTheme}
accessibilityLabel="Dark Mode"
trackColor={{ false: '#E2E8F0', true: colors.tint }}
thumbColor={'#FFFFFF'}
/>
</View>
</SettingsSection>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 4,
},
rowIconWrap: {
marginRight: 12,
},
rowText: {
flex: 1,
},
rowTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
rowSubtitle: {
fontSize: 14,
},
});
+44
View File
@@ -0,0 +1,44 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react';
import { StyleSheet, Text, TextInput, TextInputProps, View } from 'react-native';
interface LabeledInputProps extends TextInputProps {
label: string;
}
export function LabeledInput({ label, style, ...inputProps }: LabeledInputProps) {
const colors = useThemeColor();
return (
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: colors.textMuted }]}>{label}</Text>
<TextInput
style={[
styles.textInput,
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text },
style,
]}
placeholderTextColor={colors.textMuted}
{...inputProps}
/>
</View>
);
}
const styles = StyleSheet.create({
inputGroup: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
textInput: {
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 16,
},
});
@@ -0,0 +1,194 @@
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor';
import * as api from '@/services/lmStudioApi';
import { useAppStore } from '@/store/useAppStore';
import { buildServerConfig, isConfigChanged } from '@/utils/serverConfig';
import { Network } from 'lucide-react-native';
import React, { useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { LabeledInput } from './LabeledInput';
import { SettingsSection } from './SettingsSection';
function TestResultBox({ ok }: { ok: boolean }) {
const colors = useThemeColor();
return (
<View style={[styles.resultBox, { backgroundColor: ok ? colors.successBg : colors.errorBg }]}>
<Text style={[styles.resultText, { color: ok ? colors.successText : colors.errorText }]}>
{ok
? 'Connection successful. API is ready.'
: 'Connection failed. Verify host and port.'}
</Text>
</View>
);
}
export function ServerConnectionCard() {
const colors = useThemeColor();
const serverConfig = useAppStore(state => state.serverConfig);
const setServerConfig = useAppStore(state => state.setServerConfig);
const isConnected = useAppStore(state => state.isConnected);
const setIsConnected = useAppStore(state => state.setIsConnected);
const { fetchModels } = useLMStudio();
const [host, setHost] = useState(serverConfig.host);
const [port, setPort] = useState(serverConfig.port.toString());
const [apiKey, setApiKey] = useState(serverConfig.apiKey ?? '');
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<boolean | null>(null);
const form = { host, port, apiKey };
const currentConfig = buildServerConfig(form);
const configChanged = isConfigChanged(form, serverConfig);
const handleSaveConfig = () => {
setServerConfig(currentConfig);
setTestResult(null);
};
const handleTestConnection = async () => {
handleSaveConfig();
setIsTesting(true);
setTestResult(null);
const ok = await api.testConnection(currentConfig);
setTestResult(ok);
setIsConnected(ok);
if (ok) {
await fetchModels();
}
setIsTesting(false);
};
return (
<SettingsSection title="SERVER CONNECTION">
<View style={styles.cardHeader}>
<View style={styles.cardHeaderTitle}>
<Network size={20} color={colors.text} style={styles.headerIcon} />
<Text style={[styles.cardTitle, { color: colors.text }]}>LM Studio API</Text>
</View>
<ConnectionBadge isConnected={isConnected} />
</View>
<View style={[styles.divider, { backgroundColor: colors.border }]} />
<LabeledInput
label="Host IP / Address"
value={host}
onChangeText={setHost}
placeholder="localhost"
autoCapitalize="none"
autoCorrect={false}
/>
<LabeledInput
label="Port Number"
value={port}
onChangeText={setPort}
placeholder="1234"
keyboardType="numeric"
/>
<LabeledInput
label="API Key (optional)"
value={apiKey}
onChangeText={setApiKey}
placeholder="Leave empty for local"
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
/>
<View style={styles.buttonRow}>
{configChanged && (
<TouchableOpacity
style={[styles.saveButton, { borderColor: colors.border, backgroundColor: colors.cardBackground }]}
onPress={handleSaveConfig}
>
<Text style={[styles.saveButtonText, { color: colors.text }]}>Save Changes</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={[
styles.testButton,
{ backgroundColor: colors.tint },
isTesting && styles.testButtonDisabled
]}
onPress={handleTestConnection}
disabled={isTesting}
>
{isTesting ? (
<ActivityIndicator size="small" color={colors.backgroundColor} />
) : (
<Text style={[styles.testButtonText, { color: colors.backgroundColor }]}>Test Connection</Text>
)}
</TouchableOpacity>
</View>
{testResult !== null && <TestResultBox ok={testResult} />}
</SettingsSection>
);
}
const styles = StyleSheet.create({
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
},
cardHeaderTitle: {
flexDirection: 'row',
alignItems: 'center',
},
headerIcon: {
marginRight: 8,
},
cardTitle: {
fontSize: 18,
fontWeight: '700',
},
divider: {
height: 1,
width: '100%',
marginBottom: 16,
},
buttonRow: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
saveButton: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
borderWidth: 1,
},
saveButtonText: {
fontSize: 15,
fontWeight: '600',
},
testButton: {
flex: 2,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
testButtonDisabled: {
opacity: 0.7,
},
testButtonText: {
fontSize: 15,
fontWeight: '600',
},
resultBox: {
marginTop: 16,
borderRadius: 12,
padding: 14,
},
resultText: {
fontSize: 14,
textAlign: 'center',
fontWeight: '500',
},
});
+45
View File
@@ -0,0 +1,45 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import React from 'react';
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
interface SettingsSectionProps {
title: string;
children: React.ReactNode;
}
export function SettingsSection({ title, children }: SettingsSectionProps) {
const { width } = useWindowDimensions();
const colors = useThemeColor();
const isLargeScreen = width > 768;
return (
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>{title}</Text>
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
{children}
</View>
</View>
);
}
const styles = StyleSheet.create({
section: {
marginBottom: 24,
},
desktopSection: {
width: '100%',
maxWidth: 600,
},
sectionLabel: {
fontSize: 13,
fontWeight: '600',
marginLeft: 16,
marginBottom: 8,
letterSpacing: 0.5,
},
card: {
borderRadius: 16,
padding: 16,
borderWidth: 1,
},
});
-60
View File
@@ -1,60 +0,0 @@
import { StyleSheet, Text, type TextProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});
-14
View File
@@ -1,14 +0,0 @@
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}
-118
View File
@@ -1,118 +0,0 @@
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import React from 'react';
import { ActivityIndicator, Pressable, StyleSheet, Text, TextStyle, ViewStyle } from 'react-native';
interface ButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
icon?: React.ReactNode;
}
export function Button({
title,
onPress,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
style,
textStyle,
icon
}: ButtonProps) {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'dark'];
const getBackgroundColor = () => {
if (disabled) return theme.surfaceHighlight;
switch (variant) {
case 'primary': return theme.primary;
case 'secondary': return theme.surfaceHighlight;
case 'outline': return 'transparent';
case 'ghost': return 'transparent';
default: return theme.primary;
}
};
const getTextColor = () => {
if (disabled) return theme.textSecondary;
switch (variant) {
case 'primary': return theme.background; // Black text on white bg
case 'secondary': return theme.text;
case 'outline': return theme.text;
case 'ghost': return theme.textSecondary;
default: return theme.background;
}
};
const getBorder = () => {
if (variant === 'outline') return { borderWidth: 1, borderColor: theme.border };
return {};
};
const getPadding = () => {
switch (size) {
case 'sm': return { paddingVertical: 8, paddingHorizontal: 12 };
case 'lg': return { paddingVertical: 16, paddingHorizontal: 24 };
default: return { paddingVertical: 12, paddingHorizontal: 20 };
}
};
const getFontSize = () => {
switch (size) {
case 'sm': return 13;
case 'lg': return 17;
default: return 15;
}
};
return (
<Pressable
onPress={onPress}
disabled={disabled || loading}
style={({ pressed }) => [
styles.button,
{ backgroundColor: getBackgroundColor() },
getBorder(),
getPadding(),
{ opacity: pressed ? 0.8 : 1 },
style
]}
>
{loading ? (
<ActivityIndicator color={getTextColor()} />
) : (
<>
{icon}
<Text style={[
styles.text,
{ color: getTextColor(), fontSize: getFontSize() },
icon ? { marginLeft: 8 } : {},
textStyle
]}>
{title}
</Text>
</>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
text: {
fontWeight: '600',
letterSpacing: 0.5,
},
});
-36
View File
@@ -1,36 +0,0 @@
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { StyleSheet, View, ViewStyle } from 'react-native';
interface GlassCardProps {
children: React.ReactNode;
style?: ViewStyle;
variant?: 'default' | 'highlight';
}
export function GlassCard({ children, style, variant = 'default' }: GlassCardProps) {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'dark'];
return (
<View style={[
styles.card,
{
backgroundColor: variant === 'default' ? theme.surface : theme.surfaceHighlight,
borderColor: theme.border,
},
style
]}>
{children}
</View>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: 16,
borderWidth: 1,
padding: 16,
overflow: 'hidden',
},
});
-66
View File
@@ -1,66 +0,0 @@
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { StatusBar } from 'expo-status-bar';
import { ScrollView, StyleSheet, View, ViewStyle } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface ScreenWrapperProps {
children: React.ReactNode;
style?: ViewStyle;
contentContainerStyle?: ViewStyle;
scrollable?: boolean;
}
export function ScreenWrapper({
children,
style,
contentContainerStyle,
scrollable = false
}: ScreenWrapperProps) {
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'dark'];
const insets = useSafeAreaInsets();
const containerStyle = [
styles.container,
{
backgroundColor: theme.background,
paddingTop: insets.top,
paddingLeft: insets.left,
paddingRight: insets.right,
},
style
];
if (scrollable) {
return (
<View style={{ flex: 1, backgroundColor: theme.background }}>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<ScrollView
style={containerStyle}
contentContainerStyle={[
{ paddingBottom: insets.bottom + 20 },
contentContainerStyle
]}
>
{children}
</ScrollView>
</View>
);
}
return (
<View style={containerStyle}>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<View style={[{ flex: 1, paddingBottom: insets.bottom }, contentContainerStyle]}>
{children}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
-45
View File
@@ -1,45 +0,0 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});
-32
View File
@@ -1,32 +0,0 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}
-41
View File
@@ -1,41 +0,0 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}
-33
View File
@@ -1,33 +0,0 @@
export type AIModel = {
id: string;
name: string;
parameters: string;
huggingFaceUrl: string;
description: string;
};
export const QWEN_MODELS: AIModel[] = [
{
id: "qwen1.5-0.5b-chat",
name: "Qwen 1.5 Chat",
parameters: "0.5B",
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-0.5B-Chat",
description: "Ultra-lightweight model, extremely fast for basic tasks.",
},
{
id: "qwen1.5-1.8b-chat",
name: "Qwen 1.5 Chat",
parameters: "1.8B",
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-1.8B-Chat",
description: "Good balance of speed and reasoning for mobile devices.",
},
{
id: "qwen1.5-4b-chat",
name: "Qwen 1.5 Chat",
parameters: "4B",
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-4B-Chat",
description: "Highest local quality, requires more RAM and processing power.",
}
];
export const DEFAULT_MODEL = QWEN_MODELS[0];
-87
View File
@@ -1,87 +0,0 @@
/**
* ManusAI Theme - Monochromatic/Minimalist
*
* Design System:
* - Primary: White (#ffffff)
* - Background: Deep Matte Black (#09090b)
* - Surface: Zinc-900 (#18181b)
* - Border: Zinc-700 (#3f3f46)
* - Text: Zinc-50 (#fafafa)
*/
import { Platform } from 'react-native';
const ManusColors = {
// Base
black: '#09090b',
zinc900: '#18181b',
zinc800: '#27272a',
zinc700: '#3f3f46',
zinc400: '#a1a1aa',
zinc50: '#fafafa',
white: '#ffffff',
// Semantic
background: '#09090b',
surface: '#18181b',
surfaceHighlight: '#27272a',
primary: '#ffffff',
border: '#3f3f46',
text: '#fafafa',
textSecondary: '#a1a1aa',
error: '#ef4444',
};
export const Colors = {
light: {
text: ManusColors.black,
background: ManusColors.white,
tint: ManusColors.black,
icon: ManusColors.zinc700,
tabIconDefault: ManusColors.zinc400,
tabIconSelected: ManusColors.black,
// Manus Specific
surface: '#f4f4f5', // Zinc-100
surfaceHighlight: '#e4e4e7', // Zinc-200
border: '#d4d4d8', // Zinc-300
textSecondary: ManusColors.zinc700,
primary: ManusColors.black,
},
dark: {
text: ManusColors.text,
background: ManusColors.background,
tint: ManusColors.primary,
icon: ManusColors.zinc400,
tabIconDefault: ManusColors.zinc400,
tabIconSelected: ManusColors.primary,
// Manus Specific
surface: ManusColors.surface,
surfaceHighlight: ManusColors.surfaceHighlight,
border: ManusColors.border,
textSecondary: ManusColors.textSecondary,
primary: ManusColors.primary,
},
// Unified access for components that ignore system theme
manus: ManusColors,
};
export const Fonts = Platform.select({
ios: {
sans: 'system-ui',
serif: 'ui-serif',
rounded: 'ui-rounded',
mono: 'ui-monospace',
},
android: {
sans: 'sans-serif',
serif: 'serif',
rounded: 'sans-serif-medium',
mono: 'monospace',
},
default: {
sans: 'system-ui',
serif: 'serif',
rounded: 'system-ui',
mono: 'monospace',
},
});
-1
View File
@@ -1 +0,0 @@
export { useColorScheme } from 'react-native';
-21
View File
@@ -1,21 +0,0 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}
-21
View File
@@ -1,21 +0,0 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { useAppStore } from '../store/useAppStore';
import { useLMStudio } from './useLMStudio';
/**
* Checks the server once when the app opens, after the saved settings have been
* restored, so the connection badge and model list are not stale until the user
* visits Settings.
*/
export function useAutoConnect() {
const { fetchModels } = useLMStudio();
useEffect(() => {
const { persist } = useAppStore;
if (persist.hasHydrated()) {
void fetchModels();
return;
}
return persist.onFinishHydration(() => void fetchModels());
// Mount only: later checks are triggered by the user (model selector, Settings).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
+55 -199
View File
@@ -2,8 +2,16 @@ 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 { useAppStore } from '../store/useAppStore';
import { WebSearchLogger } from '../services/webSearchLogger';
import { ChatMessage, useAppStore } from '../store/useAppStore';
const NO_MODEL_MESSAGE = '⚠️ No model selected. Please select a model first.';
function buildApiMessages(messages: ChatMessage[]): ApiMessage[] {
return [
{ role: 'system', content: SYSTEM_PROMPT },
...messages.map((m) => ({ role: m.role, content: m.content })),
];
}
export function useLMStudio() {
const serverConfig = useAppStore((s) => s.serverConfig);
@@ -45,196 +53,16 @@ export function useLMStudio() {
}
}, [activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
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;
}
// Adds an empty assistant message and streams the model's answer into it.
const streamAnswer = useCallback((modelId: string, apiMessages: ApiMessage[]) => {
const assistantId = addMessage({ role: 'assistant', content: '' });
setIsStreaming(true);
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);
const finish = () => {
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(
@@ -242,30 +70,58 @@ export function useLMStudio() {
modelId,
apiMessages,
(chunk) => appendToMessage(assistantId, chunk),
() => {
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
},
finish,
(error) => {
appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
appendToMessage(assistantId, `
⚠️ Error: ${error.message}`);
finish();
},
);
} catch (err) {
appendToMessage(assistantId, `⚠️ Connection failed: ${(err as Error).message}`);
setIsStreaming(false);
useAppStore.getState().saveCurrentChat();
finish();
}
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
}, [serverConfig, addMessage, appendToMessage, setIsStreaming]);
const sendMessage = useCallback(async (text: string) => {
if (isStreaming) return;
addMessage({ role: 'user', content: text });
const modelId = useAppStore.getState().activeModelId;
if (!modelId) {
addMessage({ role: 'assistant', content: NO_MODEL_MESSAGE });
return;
}
streamAnswer(modelId, buildApiMessages(useAppStore.getState().messages));
}, [isStreaming, addMessage, streamAnswer]);
const regenerateResponse = useCallback(async () => {
if (isStreaming) return;
const { messages: msgs, activeModelId: modelId, deleteMessagesAfter } = useAppStore.getState();
const lastUserMsg = [...msgs].reverse().find((m) => m.role === 'user');
if (!lastUserMsg) return;
// Checked before deleting anything, so the current answer is not lost for nothing.
if (!modelId) {
addMessage({ role: 'assistant', content: NO_MODEL_MESSAGE });
return;
}
// Drop the previous answer(s) and ask again from the last user message.
deleteMessagesAfter(lastUserMsg.id);
streamAnswer(modelId, buildApiMessages(useAppStore.getState().messages));
}, [isStreaming, addMessage, streamAnswer]);
const stopStreaming = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
setIsStreaming(false);
useAppStore.getState().saveCurrentChat();
}
}, [setIsStreaming]);
+13
View File
@@ -0,0 +1,13 @@
module.exports = {
preset: 'jest-expo',
setupFiles: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
// jest-expo's default pattern plus the ESM-only markdown renderer.
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@ronradtke/.*)',
],
testMatch: ['<rootDir>/__tests__/**/*.test.{ts,tsx}'],
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/.agent/'],
};
+3
View File
@@ -0,0 +1,3 @@
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);
-83
View File
@@ -1,83 +0,0 @@
# 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
@@ -1,32 +0,0 @@
# 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
@@ -1,24 +0,0 @@
{
"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
@@ -1,435 +0,0 @@
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
+17 -14
View File
@@ -4,14 +4,15 @@
"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"
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"test": "jest",
"build": "expo export --platform web"
},
"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",
@@ -19,29 +20,25 @@
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@ronradtke/react-native-markdown-display": "^8.1.0",
"expo": "~54.0.31",
"expo-clipboard": "^55.0.8",
"expo-constants": "~18.0.13",
"expo-font": "~14.0.10",
"expo": "~54.0.37",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.14",
"expo-font": "~14.0.12",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.21",
"expo-linking": "~8.0.12",
"expo-router": "~6.0.24",
"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",
@@ -49,9 +46,15 @@
"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
+1 -290
View File
@@ -8,18 +8,9 @@ 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])
@@ -29,15 +20,9 @@ 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]))
@@ -71,14 +56,8 @@ 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
@@ -98,24 +77,12 @@ 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
@@ -1120,11 +1087,6 @@ 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'}
@@ -1234,12 +1196,6 @@ 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==}
@@ -1692,9 +1648,6 @@ 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==}
@@ -1762,9 +1715,6 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
[email protected]:
resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==}
@@ -1877,27 +1827,9 @@ 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==}
@@ -1987,19 +1919,6 @@ 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'}
@@ -2029,14 +1948,6 @@ 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'}
@@ -2231,13 +2142,6 @@ 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:
@@ -2633,9 +2537,6 @@ packages:
engines: {node: '>=16.x'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
[email protected]:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -2743,10 +2644,6 @@ 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'}
@@ -2984,9 +2881,6 @@ 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'}
@@ -3022,20 +2916,9 @@ 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==}
@@ -3043,22 +2926,12 @@ 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==}
@@ -3302,9 +3175,6 @@ 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==}
@@ -3558,9 +3428,6 @@ 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:
@@ -3593,15 +3460,6 @@ 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:
@@ -4105,9 +3963,6 @@ 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'}
@@ -4350,24 +4205,6 @@ 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]': {}
@@ -5648,11 +5485,6 @@ 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])':
@@ -5837,15 +5669,6 @@ 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]': {}
@@ -6374,8 +6197,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
stream-buffers: 2.2.0
@@ -6449,8 +6270,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -6581,33 +6400,10 @@ 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]:
@@ -6678,24 +6474,6 @@ 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
@@ -6718,10 +6496,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -7038,12 +6812,6 @@ 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
@@ -7469,8 +7237,6 @@ snapshots:
dependencies:
queue: 6.0.2
[email protected]: {}
[email protected]:
dependencies:
parent-module: 1.0.1
@@ -7580,8 +7346,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
call-bound: 1.0.4
@@ -7831,10 +7595,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
uc.micro: 1.0.6
[email protected]:
dependencies:
p-locate: 4.1.0
@@ -7865,40 +7625,18 @@ 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]:
@@ -8343,10 +8081,6 @@ snapshots:
semver: 7.7.4
validate-npm-package-name: 5.0.1
[email protected]:
dependencies:
boolbase: 1.0.0
[email protected]: {}
[email protected]:
@@ -8603,10 +8337,6 @@ 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
@@ -8642,16 +8372,6 @@ 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
@@ -9253,8 +8973,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
call-bound: 1.0.4
@@ -9487,10 +9205,3 @@ 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
@@ -1,112 +0,0 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
const exampleDir = "app-example";
const newAppDir = "app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);
+39 -21
View File
@@ -9,12 +9,18 @@ import {
StreamChunk,
} from './types';
function buildBaseUrl(config: ServerConfig): string {
export function buildBaseUrl(config: ServerConfig): string {
const host = config.host.replace(/\/+$/, '');
const protocol = host.startsWith('http') ? '' : 'http://';
const protocol = /^https?:\/\//i.test(host) ? '' : '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',
@@ -26,19 +32,19 @@ function buildHeaders(config: ServerConfig): Record<string, string> {
}
export async function testConnection(config: ServerConfig): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${buildBaseUrl(config)}/models`, {
headers: buildHeaders(config),
signal: controller.signal,
});
clearTimeout(timeout);
return res.ok;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
@@ -84,7 +90,7 @@ export async function chatCompletion(
}
// Parse SSE lines from a text buffer
function parseSSELines(
export function parseSSELines(
buffer: string,
onChunk: (content: string) => void,
onDone: () => void,
@@ -95,9 +101,9 @@ function parseSSELines(
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
if (!trimmed.startsWith('data:')) continue;
const payload = trimmed.slice(6);
const payload = trimmed.slice(5).trim();
if (payload === '[DONE]') {
isDone = true;
onDone();
@@ -123,6 +129,7 @@ function streamWithXHR(
url: string,
headers: Record<string, string>,
body: string,
baseUrl: string,
onChunk: (content: string) => void,
onDone: () => void,
onError: (error: Error) => void,
@@ -146,6 +153,10 @@ 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);
@@ -154,7 +165,7 @@ function streamWithXHR(
};
xhr.onerror = () => {
onError(new Error(`XHR streaming failed: ${xhr.status} ${xhr.statusText}`));
onError(connectionError(baseUrl));
};
xhr.ontimeout = () => {
@@ -175,17 +186,23 @@ 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> {
const res = await fetch(url, {
method: 'POST',
headers,
body,
signal,
});
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);
}
if (!res.ok) {
throw new Error(`Stream failed: ${res.status} ${res.statusText}`);
@@ -229,7 +246,8 @@ export function chatCompletionStream(
...options,
};
const url = `${buildBaseUrl(config)}/chat/completions`;
const baseUrl = buildBaseUrl(config);
const url = `${baseUrl}/chat/completions`;
const headers = buildHeaders(config);
const bodyStr = JSON.stringify(body);
@@ -245,7 +263,7 @@ export function chatCompletionStream(
// Web: use fetch + ReadableStream
const controller = new AbortController();
streamWithFetch(url, headers, bodyStr, onChunk, safeOnDone, onError, controller.signal)
streamWithFetch(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, controller.signal)
.catch((err) => {
if ((err as Error).name !== 'AbortError') {
onError(err as Error);
@@ -255,6 +273,6 @@ export function chatCompletionStream(
return { abort: () => controller.abort() };
} else {
// Native: use XMLHttpRequest
return streamWithXHR(url, headers, bodyStr, onChunk, safeOnDone, onError);
return streamWithXHR(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, onError);
}
}
-190
View File
@@ -1,190 +0,0 @@
/**
* 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
@@ -0,0 +1,54 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AppState } from 'react-native';
import type { StateStorage } from 'zustand/middleware';
export interface DebouncedStorage extends StateStorage {
/** Writes every pending value right away. */
flush: () => Promise<void>;
}
/**
* Wraps a storage so bursts of writes collapse into one trailing write.
*
* The chat store is persisted on every state change and a streamed answer
* changes it once per token, which would serialize every saved session and hit
* AsyncStorage dozens of times per second.
*/
export function createDebouncedStorage(base: StateStorage, delayMs = 400): DebouncedStorage {
const pending = new Map<string, string>();
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = async () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
const entries = [...pending.entries()];
pending.clear();
await Promise.all(entries.map(([name, value]) => base.setItem(name, value)));
};
return {
getItem: (name) => {
const value = pending.get(name);
return value === undefined ? base.getItem(name) : Promise.resolve(value);
},
setItem: (name, value) => {
pending.set(name, value);
if (timer) clearTimeout(timer);
timer = setTimeout(flush, delayMs);
},
removeItem: (name) => {
pending.delete(name);
return base.removeItem(name);
},
flush,
};
}
export const appStorage = createDebouncedStorage(AsyncStorage);
// Do not lose the last tokens when the app is sent to the background or closed.
AppState.addEventListener('change', (state) => {
if (state !== 'active') void appStorage.flush();
});
+3 -10
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,10 +49,6 @@ 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;
@@ -123,9 +119,6 @@ export const useAppStore = create<AppState>()(
isStreaming: false,
setIsStreaming: (isStreaming) => set({ isStreaming }),
isWebSearchEnabled: false,
setIsWebSearchEnabled: (isWebSearchEnabled) => set({ isWebSearchEnabled }),
editingMessageId: null,
setEditingMessageId: (editingMessageId) => set({ editingMessageId }),
@@ -171,14 +164,14 @@ export const useAppStore = create<AppState>()(
}),
{
name: 'localaiapp-storage',
storage: createJSONStorage(() => AsyncStorage),
storage: createJSONStorage(() => appStorage),
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
@@ -0,0 +1,16 @@
import type { ChatMessage } from '../store/useAppStore';
/** Id of the most recent assistant message, or null when there is none. */
export function findLastAssistantId(messages: ChatMessage[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'assistant') return messages[i].id;
}
return null;
}
/** True while the model has been asked to answer but has not produced any text yet. */
export function shouldShowTypingIndicator(messages: ChatMessage[], isStreaming: boolean): boolean {
if (!isStreaming) return false;
const last = messages[messages.length - 1];
return last?.role === 'assistant' && last.content === '';
}
+25
View File
@@ -0,0 +1,25 @@
import { DEFAULT_SERVER_CONFIG, ServerConfig } from '../services/types';
export interface ServerConfigForm {
host: string;
port: string;
apiKey: string;
}
/** Turns the raw text fields of the settings form into a usable ServerConfig. */
export function buildServerConfig(form: ServerConfigForm): ServerConfig {
return {
host: form.host.trim() || DEFAULT_SERVER_CONFIG.host,
port: parseInt(form.port, 10) || DEFAULT_SERVER_CONFIG.port,
apiKey: form.apiKey.trim() || undefined,
};
}
/** True when the form differs from what is currently stored. */
export function isConfigChanged(form: ServerConfigForm, saved: ServerConfig): boolean {
return (
form.host !== saved.host ||
form.port !== saved.port.toString() ||
form.apiKey !== (saved.apiKey ?? '')
);
}
+7
View File
@@ -0,0 +1,7 @@
/** Whether the dark theme is in effect for a stored preference and the OS scheme. */
export function isDarkTheme(
preference: 'light' | 'dark' | 'system',
systemScheme: 'light' | 'dark',
): boolean {
return preference === 'dark' || (preference === 'system' && systemScheme === 'dark');
}