Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e08189f640 |
No files matched your search
Binary file not shown.
Binary file not shown.
@@ -1,4 +0,0 @@
|
||||
# O diretorio .agent/ contem ferramentas de apoio ao desenvolvimento (agentes,
|
||||
# skills e rules de IA), nao faz parte do app React Native.
|
||||
.agent/** linguist-vendored
|
||||
.agent/** linguist-documentation
|
||||
@@ -1,21 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
@@ -36,10 +36,6 @@ yarn-error.*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# python (scripts de apoio em .agent/)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
coverage/
|
||||
.git/
|
||||
.pnpm-store/
|
||||
.cache/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
target/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
@@ -0,0 +1,12 @@
|
||||
|
||||
added 885 packages, changed 29 packages, and audited 915 packages in 22s
|
||||
|
||||
166 packages are looking for funding
|
||||
run `npm fund` for details
|
||||
|
||||
4 vulnerabilities (1 moderate, 3 high)
|
||||
|
||||
To address all issues, run:
|
||||
npm audit fix
|
||||
|
||||
Run `npm audit` for details.
|
||||
@@ -1,190 +1,79 @@
|
||||
# LocalMind
|
||||
|
||||
[](https://github.com/evandrodevbr/LocalMind/actions/workflows/ci.yml)
|
||||
   
|
||||
|
||||
**Chat client for local LLMs served by LM Studio. React Native + Expo app (Android, iOS and web) where every message stays on your own network: no cloud, no API key, no telemetry.**
|
||||
> A fully local, privacy-first AI companion app built with React Native and Expo. Your data never leaves your device.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

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