Compare commits
12
Commits
main
...
refine/2026-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8d7d61e9a | ||
|
|
541aefd7e4 | ||
|
|
1a3ce9ccec | ||
|
|
b441339bb4 | ||
|
|
0e5b38fd15 | ||
|
|
200fb6afd7 | ||
|
|
7ee78af76d | ||
|
|
2a294f1f87 | ||
|
|
434cba2a69 | ||
|
|
cb6eda0562 | ||
|
|
808cb1610b | ||
|
|
37dfa265f5 |
No files matched your search
@@ -0,0 +1,21 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.**
|
||||
|
||||

|
||||
@@ -32,7 +34,8 @@ Expo app (Android / iOS / web)
|
||||
- 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 exchange is saved as a chat session (title taken from the first user message), so History can reload it later.
|
||||
- Every finished (or stopped) exchange is saved as a chat session (title taken from the first user message), so History can reload it later. Writes to `AsyncStorage` are debounced (400 ms) so streaming does not serialize the whole history on every token.
|
||||
- When the chat opens, the app checks the saved server once and loads its model list; the selected model is remembered between launches.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -45,13 +48,13 @@ Expo app (Android / iOS / web)
|
||||
| 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`) |
|
||||
| Tooling | TypeScript 5.9 (`strict`), ESLint 9 flat config (`eslint-config-expo`), Jest 29 with `jest-expo` and Testing Library |
|
||||
| Model server | LM Studio local server, OpenAI-compatible `/v1` API |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js `>= 20.19.4` (required by React Native 0.81.5; verified here on Node 24.20.0)
|
||||
- npm (or pnpm) to install dependencies
|
||||
- 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
|
||||
|
||||
@@ -95,7 +98,7 @@ The app is a client of the LM Studio OpenAI-compatible API. Endpoints actually u
|
||||
| `GET /v1/models` | `testConnection()`, `getModels()` | The connection test aborts after 5 s; the model list feeds the header selector. |
|
||||
| `POST /v1/chat/completions` | `chatCompletion()`, `chatCompletionStream()` | Streaming (`stream: true`) is the path used by the chat; SSE lines with `choices[0].delta.content` are appended to the last message. |
|
||||
|
||||
- Base URL: `http://<host>:<port>/v1` (`http://` is prepended when the host has no scheme).
|
||||
- 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": ... } ] }`.
|
||||
|
||||
@@ -118,52 +121,62 @@ app/
|
||||
├── index.tsx redirects to /chat
|
||||
├── +not-found.tsx 404 screen
|
||||
├── chat/
|
||||
│ ├── _layout.tsx chat header (model selector, history, new chat, settings)
|
||||
│ ├── index.tsx conversation screen, prompt suggestions, edit/regenerate
|
||||
│ ├── _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 server connection, appearance, about
|
||||
└── settings/index.tsx composes the settings cards
|
||||
components/
|
||||
├── chat/ ChatInput, MessageBubble, MessageActions, ModelSelector, TypingIndicator
|
||||
└── ui/ Button, GlassCard, ConnectionBadge, ScreenWrapper, icons
|
||||
constants/ Colors, theme, SystemPrompt (system prompt sent to the model)
|
||||
hooks/ useLMStudio (chat flow), useThemeColor, useColorScheme
|
||||
├── 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 + AsyncStorage)
|
||||
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
|
||||
|
||||
There is no automated test suite in this repository. What exists and was actually run on Linux (Node 24.20.0, npm 11.19.0):
|
||||
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.
|
||||
|
||||
| Command | Result |
|
||||
|---|---|
|
||||
| `npm install` | exit 0 |
|
||||
| `npx tsc --noEmit` | exit 0, no type errors |
|
||||
| `npx expo lint` | exit 0, 0 errors and 3 warnings (`no-require-imports` twice and one `exhaustive-deps` in `app/chat/index.tsx`) |
|
||||
| `npx expo export --platform web` | exit 0, 6 static routes in `dist/` |
|
||||
| `npx expo start --web` | dev server serves the app on `http://localhost:8083`, no browser errors |
|
||||
|
||||
End-to-end run of the exported web build in headless Chromium, against a local mock of the LM Studio `/v1` API:
|
||||
|
||||
- `GET /v1/models` and `POST /v1/chat/completions` (SSE) reached the app: Test Connection returned *Connection successful*, and the header listed the mock model;
|
||||
- a message sent from the chat was answered by the mock and rendered incrementally: `Ola! Sou um modelo local rodando via mock do LM Studio. Teste E2E concluido com sucesso.`;
|
||||
- the session was persisted (`localStorage` key `localaiapp-storage`) and showed up in History with `2 msgs`;
|
||||
- zero uncaught page errors after the `import.meta` fix described below.
|
||||
|
||||
Not verified in this environment: Android and iOS builds (no emulator or device available), and therefore the native streaming path via `XMLHttpRequest`.
|
||||
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.
|
||||
- No auto-connect on launch: the app only checks the server from Settings or when the model selector is refreshed. Until then the chat shows *Server offline*.
|
||||
- `activeModelId` and `availableModels` are not persisted, so after restarting the app you have to select a model again.
|
||||
- `constants/Models.ts` (a Qwen model catalog) is not referenced anywhere in the app.
|
||||
- `react-native-sse` is declared in `package.json` but no code imports it.
|
||||
- Both `package-lock.json` and `pnpm-lock.yaml` are committed.
|
||||
- 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 CI, no tests, no license file.
|
||||
- No license file.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
import { ChatInput } from '../components/chat/ChatInput';
|
||||
|
||||
describe('ChatInput', () => {
|
||||
it('sends trimmed text, clears the field and disables send while empty', () => {
|
||||
const onSend = jest.fn();
|
||||
render(<ChatInput onSend={onSend} />);
|
||||
|
||||
expect(screen.getByLabelText('Send message').props.accessibilityState.disabled).toBe(true);
|
||||
|
||||
fireEvent.changeText(screen.getByLabelText('Message'), ' hello ');
|
||||
fireEvent.press(screen.getByLabelText('Send message'));
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('hello');
|
||||
expect(screen.getByLabelText('Message').props.value).toBe('');
|
||||
});
|
||||
|
||||
it('shows a stop button instead of send while loading', () => {
|
||||
const onStop = jest.fn();
|
||||
render(<ChatInput onSend={jest.fn()} onStop={onStop} isLoading />);
|
||||
|
||||
expect(screen.queryByLabelText('Send message')).toBeNull();
|
||||
fireEvent.press(screen.getByLabelText('Stop generating'));
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fills the field when editing and lets the user cancel', () => {
|
||||
const onCancelEdit = jest.fn();
|
||||
render(<ChatInput onSend={jest.fn()} editingText="old text" onCancelEdit={onCancelEdit} />);
|
||||
|
||||
expect(screen.getByText('Editing message')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Message').props.value).toBe('old text');
|
||||
|
||||
fireEvent.press(screen.getByLabelText('Cancel editing'));
|
||||
expect(onCancelEdit).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByLabelText('Message').props.value).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
import ChatScreen from '../app/chat/index';
|
||||
import * as api from '../services/lmStudioApi';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
|
||||
jest.mock('../services/lmStudioApi');
|
||||
jest.mock('react-native-reanimated', () => {
|
||||
const { View } = require('react-native');
|
||||
const chain: any = new Proxy(() => chain, { get: () => chain });
|
||||
return { __esModule: true, default: { View }, FadeInDown: chain };
|
||||
});
|
||||
jest.mock('../components/chat/MessageBubble', () => {
|
||||
const { Text } = require('react-native');
|
||||
return {
|
||||
MessageBubble: ({ message, isLastAssistant }: any) => (
|
||||
<Text testID={`bubble-${message.id}`}>{`${message.role}:${message.content}${isLastAssistant ? ':last' : ''}`}</Text>
|
||||
),
|
||||
};
|
||||
});
|
||||
jest.mock('../components/chat/TypingIndicator', () => {
|
||||
const { Text } = require('react-native');
|
||||
return { TypingIndicator: () => <Text>typing-indicator</Text> };
|
||||
});
|
||||
|
||||
let inputProps: any;
|
||||
jest.mock('../components/chat/ChatInput', () => ({
|
||||
ChatInput: (props: any) => {
|
||||
inputProps = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const streamMock = api.chatCompletionStream as jest.Mock;
|
||||
const state = () => useAppStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
streamMock.mockReset();
|
||||
useAppStore.setState(useAppStore.getInitialState(), true);
|
||||
useAppStore.setState({ isConnected: true, activeModelId: 'model-a' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('ChatScreen', () => {
|
||||
it('shows the empty state with prompt suggestions when there are no messages', () => {
|
||||
render(<ChatScreen />);
|
||||
expect(screen.getByText('Start a conversation')).toBeTruthy();
|
||||
expect(screen.getByText('Explain recursion in simple terms')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sends a suggestion as a user message and starts streaming', () => {
|
||||
render(<ChatScreen />);
|
||||
fireEvent.press(screen.getByText('Explain recursion in simple terms'));
|
||||
|
||||
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual([
|
||||
'user:Explain recursion in simple terms',
|
||||
'assistant:',
|
||||
]);
|
||||
expect(streamMock).toHaveBeenCalledTimes(1);
|
||||
expect(streamMock.mock.calls[0][1]).toBe('model-a');
|
||||
});
|
||||
|
||||
it('hides the empty state and lists the messages once there are some', () => {
|
||||
state().addMessage({ role: 'user', content: 'hello' });
|
||||
render(<ChatScreen />);
|
||||
expect(screen.queryByText('Start a conversation')).toBeNull();
|
||||
expect(screen.getByText('user:hello')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the offline banner only when the server is not connected', () => {
|
||||
const { rerender } = render(<ChatScreen />);
|
||||
expect(screen.queryByText(/Server offline/)).toBeNull();
|
||||
|
||||
act(() => {
|
||||
state().setIsConnected(false);
|
||||
});
|
||||
rerender(<ChatScreen />);
|
||||
expect(screen.getByText(/Server offline/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the typing indicator only while an empty assistant message is streaming', () => {
|
||||
state().addMessage({ role: 'user', content: 'q' });
|
||||
const assistantId = state().addMessage({ role: 'assistant', content: '' });
|
||||
act(() => {
|
||||
state().setIsStreaming(true);
|
||||
});
|
||||
render(<ChatScreen />);
|
||||
expect(screen.getByText('typing-indicator')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
state().appendToMessage(assistantId, 'partial');
|
||||
});
|
||||
expect(screen.queryByText('typing-indicator')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not show the typing indicator when not streaming', () => {
|
||||
state().addMessage({ role: 'assistant', content: '' });
|
||||
render(<ChatScreen />);
|
||||
expect(screen.queryByText('typing-indicator')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks only the last assistant message as regenerable', () => {
|
||||
state().addMessage({ role: 'user', content: 'q1' });
|
||||
state().addMessage({ role: 'assistant', content: 'a1' });
|
||||
state().addMessage({ role: 'user', content: 'q2' });
|
||||
state().addMessage({ role: 'assistant', content: 'a2' });
|
||||
render(<ChatScreen />);
|
||||
expect(screen.getByText('assistant:a1')).toBeTruthy();
|
||||
expect(screen.getByText('assistant:a2:last')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sends typed text through the normal path', () => {
|
||||
render(<ChatScreen />);
|
||||
act(() => {
|
||||
inputProps.onSend('typed');
|
||||
});
|
||||
expect(state().messages[0]).toMatchObject({ role: 'user', content: 'typed' });
|
||||
expect(streamMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('editing a message', () => {
|
||||
const setup = () => {
|
||||
state().addMessage({ role: 'user', content: 'old question' });
|
||||
state().addMessage({ role: 'assistant', content: 'old answer' });
|
||||
const id = state().messages[0].id;
|
||||
render(<ChatScreen />);
|
||||
act(() => {
|
||||
state().setEditingMessageId(id);
|
||||
});
|
||||
return id;
|
||||
};
|
||||
|
||||
it('passes the edited message text to the input and clears edit mode on cancel', () => {
|
||||
setup();
|
||||
expect(inputProps.editingText).toBe('old question');
|
||||
act(() => {
|
||||
inputProps.onCancelEdit();
|
||||
});
|
||||
expect(state().editingMessageId).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites the message, drops the answers after it and regenerates', () => {
|
||||
const id = setup();
|
||||
act(() => {
|
||||
inputProps.onSend('new question');
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(state().editingMessageId).toBeNull();
|
||||
expect(state().messages[0]).toMatchObject({ id, content: 'new question', isEdited: true });
|
||||
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual([
|
||||
'user:new question',
|
||||
'assistant:',
|
||||
]);
|
||||
expect(streamMock).toHaveBeenCalledTimes(1);
|
||||
const [, modelId, apiMessages] = streamMock.mock.calls[0];
|
||||
expect(modelId).toBe('model-a');
|
||||
expect(apiMessages.slice(1)).toEqual([{ role: 'user', content: 'new question' }]);
|
||||
expect(apiMessages[0].role).toBe('system');
|
||||
});
|
||||
|
||||
it('edits and explains that a model is needed when none is selected', () => {
|
||||
const id = setup();
|
||||
act(() => {
|
||||
useAppStore.setState({ activeModelId: '' });
|
||||
});
|
||||
act(() => {
|
||||
inputProps.onSend('new question');
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(state().messages).toHaveLength(2);
|
||||
expect(state().messages[0]).toMatchObject({ id, content: 'new question' });
|
||||
expect(state().messages[1].content).toContain('No model selected');
|
||||
expect(streamMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets the user stop an answer generated after an edit', () => {
|
||||
const abort = jest.fn();
|
||||
streamMock.mockReturnValue({ abort });
|
||||
setup();
|
||||
act(() => {
|
||||
inputProps.onSend('new question');
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
act(() => {
|
||||
inputProps.onStop();
|
||||
});
|
||||
expect(abort).toHaveBeenCalledTimes(1);
|
||||
expect(state().isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('saves the edited conversation when the regenerated answer finishes', () => {
|
||||
streamMock.mockImplementation((_c, _m, _msgs, onChunk, onDone) => {
|
||||
onChunk('fresh answer');
|
||||
onDone();
|
||||
return { abort: jest.fn() };
|
||||
});
|
||||
setup();
|
||||
act(() => {
|
||||
inputProps.onSend('new question');
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(state().chatSessions).toHaveLength(1);
|
||||
expect(state().chatSessions[0].messages.map((m) => m.content)).toEqual(['new question', 'fresh answer']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react-native';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import React from 'react';
|
||||
import { MessageBubble } from '../components/chat/MessageBubble';
|
||||
import { ChatMessage } from '../store/useAppStore';
|
||||
|
||||
jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn(() => Promise.resolve(true)) }));
|
||||
|
||||
let actionsProps: any;
|
||||
jest.mock('../components/chat/MessageActions', () => ({
|
||||
MessageActions: (props: any) => {
|
||||
actionsProps = props;
|
||||
const { Text } = require('react-native');
|
||||
return <Text>actions</Text>;
|
||||
},
|
||||
}));
|
||||
|
||||
const message = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
|
||||
id: '1',
|
||||
role: 'assistant',
|
||||
content: 'hello **world**',
|
||||
timestamp: new Date(2026, 0, 1, 13, 5).getTime(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
actionsProps = undefined;
|
||||
(Clipboard.setStringAsync as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('MessageBubble user message', () => {
|
||||
it('shows the plain text and keeps the actions hidden until touched', () => {
|
||||
render(<MessageBubble message={message({ role: 'user', content: 'hi there' })} />);
|
||||
expect(screen.getByText('hi there')).toBeTruthy();
|
||||
expect(screen.queryByText('actions')).toBeNull();
|
||||
});
|
||||
|
||||
it('reveals edit and delete (but not regenerate) after a touch', () => {
|
||||
const onEdit = jest.fn();
|
||||
const onDelete = jest.fn();
|
||||
const onRegenerate = jest.fn();
|
||||
render(
|
||||
<MessageBubble
|
||||
message={message({ role: 'user', content: 'hi' })}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onRegenerate={onRegenerate}
|
||||
isLastAssistant
|
||||
/>,
|
||||
);
|
||||
fireEvent(screen.getByText('hi').parent!.parent!.parent!.parent!, 'touchStart');
|
||||
|
||||
expect(screen.getByText('actions')).toBeTruthy();
|
||||
expect(actionsProps.role).toBe('user');
|
||||
expect(actionsProps.onEdit).toBe(onEdit);
|
||||
expect(actionsProps.onDelete).toBe(onDelete);
|
||||
expect(actionsProps.onRegenerate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never renders actions for an empty message', () => {
|
||||
render(<MessageBubble message={message({ role: 'user', content: '' })} />);
|
||||
fireEvent(screen.getByText(/\d/).parent!.parent!.parent!, 'touchStart');
|
||||
expect(screen.queryByText('actions')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageBubble assistant message', () => {
|
||||
it('renders markdown and always shows actions', () => {
|
||||
render(<MessageBubble message={message()} />);
|
||||
expect(screen.getByText('world')).toBeTruthy();
|
||||
expect(screen.getByText('actions')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('offers regenerate only on the last assistant message and never edit', () => {
|
||||
const onEdit = jest.fn();
|
||||
const onRegenerate = jest.fn();
|
||||
const { rerender } = render(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} />);
|
||||
expect(actionsProps.onRegenerate).toBeUndefined();
|
||||
expect(actionsProps.onEdit).toBeUndefined();
|
||||
|
||||
rerender(<MessageBubble message={message()} onEdit={onEdit} onRegenerate={onRegenerate} isLastAssistant />);
|
||||
expect(actionsProps.onRegenerate).toBe(onRegenerate);
|
||||
expect(actionsProps.onEdit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('flags actions as streaming only for the last assistant message', () => {
|
||||
const { rerender } = render(<MessageBubble message={message()} isStreaming />);
|
||||
expect(actionsProps.isStreaming).toBeFalsy();
|
||||
|
||||
rerender(<MessageBubble message={message()} isStreaming isLastAssistant />);
|
||||
expect(actionsProps.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it('renders nothing inside the bubble or actions while an empty answer is streaming', () => {
|
||||
render(<MessageBubble message={message({ content: '' })} isStreaming isLastAssistant />);
|
||||
expect(screen.queryByText('actions')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders an empty answer as markdown when not streaming', () => {
|
||||
render(<MessageBubble message={message({ content: '' })} />);
|
||||
expect(screen.queryByText('actions')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageBubble metadata', () => {
|
||||
it('shows the time and the edited badge only for edited messages', () => {
|
||||
const { rerender } = render(<MessageBubble message={message({ role: 'user' })} />);
|
||||
expect(screen.queryByText(/edited/)).toBeNull();
|
||||
|
||||
rerender(<MessageBubble message={message({ role: 'user', isEdited: true })} />);
|
||||
expect(screen.getByText(/· edited/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageBubble code blocks', () => {
|
||||
const fenced = 'text\n\n```ts\nconst a = 1;\n```\n';
|
||||
|
||||
it('shows the language, the code and copies it to the clipboard', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
render(<MessageBubble message={message({ content: fenced })} />);
|
||||
expect(screen.getByText('ts')).toBeTruthy();
|
||||
expect(screen.getByText(/const a = 1;/)).toBeTruthy();
|
||||
expect(screen.getByText('Copy')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByText('Copy'));
|
||||
});
|
||||
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('const a = 1;\n');
|
||||
expect(screen.getByText('Copied!')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(screen.getByText('Copy')).toBeTruthy();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('labels a fence without a language as "code"', () => {
|
||||
render(<MessageBubble message={message({ content: '```\nplain\n```\n' })} />);
|
||||
expect(screen.getByText('code')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders indented code blocks with a "code" header and copy button', async () => {
|
||||
render(<MessageBubble message={message({ content: 'intro\n\n indented()\n' })} />);
|
||||
expect(screen.getByText('code')).toBeTruthy();
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByText('Copy'));
|
||||
});
|
||||
expect(Clipboard.setStringAsync).toHaveBeenCalledWith(expect.stringContaining('indented()'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { render, screen } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
import { MessageList } from '../components/chat/MessageList';
|
||||
import { ChatMessage } from '../store/useAppStore';
|
||||
|
||||
jest.mock('react-native-reanimated', () => {
|
||||
const { View } = require('react-native');
|
||||
const chain: any = new Proxy(() => chain, { get: () => chain });
|
||||
return { __esModule: true, default: { View }, FadeInDown: chain };
|
||||
});
|
||||
|
||||
const mockRenders: Record<string, number> = {};
|
||||
jest.mock('../components/chat/MessageBubble', () => {
|
||||
const { Text } = require('react-native');
|
||||
return {
|
||||
MessageBubble: ({ message }: any) => {
|
||||
mockRenders[message.id] = (mockRenders[message.id] ?? 0) + 1;
|
||||
return <Text>{`${message.id}:${message.content}`}</Text>;
|
||||
},
|
||||
};
|
||||
});
|
||||
jest.mock('../components/chat/TypingIndicator', () => {
|
||||
const { Text } = require('react-native');
|
||||
return { TypingIndicator: () => <Text>typing-indicator</Text> };
|
||||
});
|
||||
|
||||
const msg = (id: string, role: ChatMessage['role'], content: string): ChatMessage => ({
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
timestamp: 0,
|
||||
});
|
||||
|
||||
const handlers = { onEdit: jest.fn(), onDelete: jest.fn(), onRegenerate: jest.fn() };
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(mockRenders).forEach((k) => delete mockRenders[k]);
|
||||
});
|
||||
|
||||
describe('MessageList', () => {
|
||||
it('renders every message', () => {
|
||||
render(
|
||||
<MessageList
|
||||
messages={[msg('u1', 'user', 'q'), msg('a1', 'assistant', 'a')]}
|
||||
isStreaming={false}
|
||||
{...handlers}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('u1:q')).toBeTruthy();
|
||||
expect(screen.getByText('a1:a')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('re-renders only the message that changed while an answer streams in', () => {
|
||||
const user = msg('u1', 'user', 'question');
|
||||
const earlier = msg('a0', 'assistant', 'earlier answer');
|
||||
const streaming = msg('a1', 'assistant', 'He');
|
||||
|
||||
const { rerender } = render(
|
||||
<MessageList messages={[user, earlier, streaming]} isStreaming {...handlers} />,
|
||||
);
|
||||
expect(mockRenders).toEqual({ u1: 1, a0: 1, a1: 1 });
|
||||
|
||||
rerender(
|
||||
<MessageList messages={[user, earlier, { ...streaming, content: 'Hello' }]} isStreaming {...handlers} />,
|
||||
);
|
||||
expect(screen.getByText('a1:Hello')).toBeTruthy();
|
||||
expect(mockRenders.u1).toBe(1);
|
||||
expect(mockRenders.a0).toBe(1);
|
||||
expect(mockRenders.a1).toBe(2);
|
||||
});
|
||||
|
||||
it('shows the typing indicator while the last assistant message is still empty', () => {
|
||||
render(
|
||||
<MessageList messages={[msg('u1', 'user', 'q'), msg('a1', 'assistant', '')]} isStreaming {...handlers} />,
|
||||
);
|
||||
expect(screen.getByText('typing-indicator')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react-native';
|
||||
import React from 'react';
|
||||
import SettingsScreen from '../app/settings/index';
|
||||
import * as api from '../services/lmStudioApi';
|
||||
import { DEFAULT_SERVER_CONFIG } from '../services/types';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
|
||||
jest.mock('../services/lmStudioApi');
|
||||
|
||||
let mockSystemScheme: 'light' | 'dark' = 'light';
|
||||
jest.mock('../hooks/useColorScheme', () => ({
|
||||
useColorScheme: () => mockSystemScheme,
|
||||
}));
|
||||
|
||||
const mockApi = api as jest.Mocked<typeof api>;
|
||||
const state = () => useAppStore.getState();
|
||||
|
||||
const hostInput = () => screen.getByPlaceholderText('localhost');
|
||||
const portInput = () => screen.getByPlaceholderText('1234');
|
||||
const keyInput = () => screen.getByPlaceholderText('Leave empty for local');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockSystemScheme = 'light';
|
||||
useAppStore.setState(useAppStore.getInitialState(), true);
|
||||
mockApi.getModels.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe('SettingsScreen connection form', () => {
|
||||
it('is filled from the stored configuration and hides "Save Changes" until something changes', () => {
|
||||
useAppStore.setState({ serverConfig: { host: '192.168.0.5', port: 4321, apiKey: 'k' } });
|
||||
render(<SettingsScreen />);
|
||||
|
||||
expect(hostInput().props.value).toBe('192.168.0.5');
|
||||
expect(portInput().props.value).toBe('4321');
|
||||
expect(keyInput().props.value).toBe('k');
|
||||
expect(screen.queryByText('Save Changes')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows "Save Changes" for each changed field', () => {
|
||||
render(<SettingsScreen />);
|
||||
|
||||
fireEvent.changeText(hostInput(), 'other');
|
||||
expect(screen.getByText('Save Changes')).toBeTruthy();
|
||||
fireEvent.changeText(hostInput(), DEFAULT_SERVER_CONFIG.host);
|
||||
expect(screen.queryByText('Save Changes')).toBeNull();
|
||||
|
||||
fireEvent.changeText(portInput(), '999');
|
||||
expect(screen.getByText('Save Changes')).toBeTruthy();
|
||||
fireEvent.changeText(portInput(), String(DEFAULT_SERVER_CONFIG.port));
|
||||
expect(screen.queryByText('Save Changes')).toBeNull();
|
||||
|
||||
fireEvent.changeText(keyInput(), 'secret');
|
||||
expect(screen.getByText('Save Changes')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('saves trimmed values', () => {
|
||||
render(<SettingsScreen />);
|
||||
fireEvent.changeText(hostInput(), ' 192.168.0.9 ');
|
||||
fireEvent.changeText(portInput(), '8080');
|
||||
fireEvent.changeText(keyInput(), ' key ');
|
||||
fireEvent.press(screen.getByText('Save Changes'));
|
||||
|
||||
expect(state().serverConfig).toEqual({ host: '192.168.0.9', port: 8080, apiKey: 'key' });
|
||||
});
|
||||
|
||||
it('falls back to localhost:1234 and no API key when the fields are blank or invalid', () => {
|
||||
useAppStore.setState({ serverConfig: { host: 'lan', port: 4321, apiKey: 'k' } });
|
||||
render(<SettingsScreen />);
|
||||
fireEvent.changeText(hostInput(), ' ');
|
||||
fireEvent.changeText(portInput(), 'abc');
|
||||
fireEvent.changeText(keyInput(), ' ');
|
||||
fireEvent.press(screen.getByText('Save Changes'));
|
||||
|
||||
expect(state().serverConfig).toEqual({ host: 'localhost', port: 1234, apiKey: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsScreen connection test', () => {
|
||||
it('saves the configuration, reports success and loads the models', async () => {
|
||||
mockApi.testConnection.mockResolvedValue(true);
|
||||
render(<SettingsScreen />);
|
||||
fireEvent.changeText(hostInput(), '10.0.0.2');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByText('Test Connection'));
|
||||
});
|
||||
|
||||
expect(mockApi.testConnection).toHaveBeenCalledWith({ host: '10.0.0.2', port: 1234, apiKey: undefined });
|
||||
expect(state().serverConfig.host).toBe('10.0.0.2');
|
||||
expect(state().isConnected).toBe(true);
|
||||
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('Connection successful. API is ready.')).toBeTruthy();
|
||||
expect(screen.getByText('Connected')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reports failure and does not load models', async () => {
|
||||
mockApi.testConnection.mockResolvedValue(false);
|
||||
render(<SettingsScreen />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByText('Test Connection'));
|
||||
});
|
||||
|
||||
expect(state().isConnected).toBe(false);
|
||||
expect(mockApi.getModels).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Connection failed. Verify host and port.')).toBeTruthy();
|
||||
expect(screen.getByText('Disconnected')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows a spinner and ignores presses while the test is running', async () => {
|
||||
let resolveTest: (ok: boolean) => void = () => {};
|
||||
mockApi.testConnection.mockReturnValue(new Promise((resolve) => (resolveTest = resolve)));
|
||||
render(<SettingsScreen />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByText('Test Connection'));
|
||||
});
|
||||
expect(screen.queryByText('Test Connection')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveTest(false);
|
||||
});
|
||||
expect(screen.getByText('Test Connection')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('clears a previous result when the configuration is saved again', async () => {
|
||||
mockApi.testConnection.mockResolvedValue(false);
|
||||
render(<SettingsScreen />);
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByText('Test Connection'));
|
||||
});
|
||||
expect(screen.getByText(/Connection failed/)).toBeTruthy();
|
||||
|
||||
fireEvent.changeText(hostInput(), 'changed');
|
||||
fireEvent.press(screen.getByText('Save Changes'));
|
||||
expect(screen.queryByText(/Connection failed/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsScreen dark mode switch', () => {
|
||||
const darkSwitch = () => screen.UNSAFE_getByType(require('react-native').Switch);
|
||||
|
||||
it('is on for the dark preference and switches to light', () => {
|
||||
useAppStore.setState({ theme: 'dark' });
|
||||
render(<SettingsScreen />);
|
||||
expect(darkSwitch().props.value).toBe(true);
|
||||
|
||||
fireEvent(darkSwitch(), 'valueChange', false);
|
||||
expect(state().theme).toBe('light');
|
||||
});
|
||||
|
||||
it('is off for the light preference and switches to dark', () => {
|
||||
useAppStore.setState({ theme: 'light' });
|
||||
render(<SettingsScreen />);
|
||||
expect(darkSwitch().props.value).toBe(false);
|
||||
|
||||
fireEvent(darkSwitch(), 'valueChange', true);
|
||||
expect(state().theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('follows the system scheme when the preference is "system"', () => {
|
||||
mockSystemScheme = 'dark';
|
||||
const first = render(<SettingsScreen />);
|
||||
expect(darkSwitch().props.value).toBe(true);
|
||||
first.unmount();
|
||||
|
||||
mockSystemScheme = 'light';
|
||||
render(<SettingsScreen />);
|
||||
expect(darkSwitch().props.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createDebouncedStorage } from '../store/debouncedStorage';
|
||||
|
||||
const makeBase = () => ({
|
||||
getItem: jest.fn(async (_name: string): Promise<string | null> => 'from-disk'),
|
||||
setItem: jest.fn(async (_name: string, _value: string) => {}),
|
||||
removeItem: jest.fn(async (_name: string) => {}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('createDebouncedStorage', () => {
|
||||
it('collapses a burst of writes into one trailing write with the latest value', () => {
|
||||
const base = makeBase();
|
||||
const storage = createDebouncedStorage(base, 400);
|
||||
|
||||
storage.setItem('k', '1');
|
||||
jest.advanceTimersByTime(300);
|
||||
storage.setItem('k', '2');
|
||||
jest.advanceTimersByTime(300);
|
||||
storage.setItem('k', '3');
|
||||
expect(base.setItem).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(400);
|
||||
expect(base.setItem).toHaveBeenCalledTimes(1);
|
||||
expect(base.setItem).toHaveBeenCalledWith('k', '3');
|
||||
});
|
||||
|
||||
it('reads a pending value before it reaches the disk, and falls back to the base storage', async () => {
|
||||
const base = makeBase();
|
||||
const storage = createDebouncedStorage(base);
|
||||
|
||||
await expect(storage.getItem('k')).resolves.toBe('from-disk');
|
||||
storage.setItem('k', 'fresh');
|
||||
await expect(storage.getItem('k')).resolves.toBe('fresh');
|
||||
});
|
||||
|
||||
it('flush writes immediately and cancels the timer', async () => {
|
||||
const base = makeBase();
|
||||
const storage = createDebouncedStorage(base);
|
||||
|
||||
storage.setItem('a', '1');
|
||||
storage.setItem('b', '2');
|
||||
await storage.flush();
|
||||
expect(base.setItem.mock.calls).toEqual([['a', '1'], ['b', '2']]);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(base.setItem).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('flush with nothing pending does not write', async () => {
|
||||
const base = makeBase();
|
||||
await createDebouncedStorage(base).flush();
|
||||
expect(base.setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removeItem drops a pending write and removes from the base storage', async () => {
|
||||
const base = makeBase();
|
||||
const storage = createDebouncedStorage(base);
|
||||
|
||||
storage.setItem('k', '1');
|
||||
await storage.removeItem('k');
|
||||
jest.advanceTimersByTime(1000);
|
||||
|
||||
expect(base.setItem).not.toHaveBeenCalled();
|
||||
expect(base.removeItem).toHaveBeenCalledWith('k');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
import { Platform } from 'react-native';
|
||||
import {
|
||||
buildBaseUrl,
|
||||
chatCompletion,
|
||||
chatCompletionStream,
|
||||
getModels,
|
||||
parseSSELines,
|
||||
testConnection,
|
||||
} from '../services/lmStudioApi';
|
||||
import { ServerConfig } from '../services/types';
|
||||
|
||||
const config: ServerConfig = { host: '192.168.0.10', port: 1234 };
|
||||
|
||||
const fetchMock = jest.fn();
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const sseLine = (content: string) => `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}`;
|
||||
|
||||
describe('buildBaseUrl', () => {
|
||||
it('prepends http:// and appends port and /v1', () => {
|
||||
expect(buildBaseUrl(config)).toBe('http://192.168.0.10:1234/v1');
|
||||
});
|
||||
|
||||
it('keeps an explicit http or https scheme', () => {
|
||||
expect(buildBaseUrl({ host: 'http://lan.local', port: 8080 })).toBe('http://lan.local:8080/v1');
|
||||
expect(buildBaseUrl({ host: 'https://lan.local', port: 443 })).toBe('https://lan.local:443/v1');
|
||||
});
|
||||
|
||||
it('strips trailing slashes from the host', () => {
|
||||
expect(buildBaseUrl({ host: 'http://lan.local//', port: 1234 })).toBe('http://lan.local:1234/v1');
|
||||
});
|
||||
|
||||
it('does not mistake a host that merely starts with "http" for a scheme', () => {
|
||||
expect(buildBaseUrl({ host: 'httpbin.local', port: 1234 })).toBe('http://httpbin.local:1234/v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSSELines', () => {
|
||||
it('emits content of every complete data line and keeps the partial tail', () => {
|
||||
const onChunk = jest.fn();
|
||||
const onDone = jest.fn();
|
||||
const result = parseSSELines(`${sseLine('Hel')}\n${sseLine('lo')}\ndata: {"cho`, onChunk, onDone);
|
||||
|
||||
expect(onChunk.mock.calls).toEqual([['Hel'], ['lo']]);
|
||||
expect(result).toEqual({ remaining: 'data: {"cho', isDone: false });
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops at [DONE] and calls onDone', () => {
|
||||
const onChunk = jest.fn();
|
||||
const onDone = jest.fn();
|
||||
const result = parseSSELines(`${sseLine('a')}\ndata: [DONE]\n${sseLine('ignored')}\n`, onChunk, onDone);
|
||||
|
||||
expect(onChunk.mock.calls).toEqual([['a']]);
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ remaining: '', isDone: true });
|
||||
});
|
||||
|
||||
it('skips malformed JSON, empty deltas, comments and non-data lines', () => {
|
||||
const onChunk = jest.fn();
|
||||
parseSSELines(
|
||||
[
|
||||
'data: {not json}',
|
||||
'data: {"choices":[{"delta":{}}]}',
|
||||
'data: {"choices":[]}',
|
||||
': keep-alive',
|
||||
'event: message',
|
||||
'',
|
||||
sseLine('ok'),
|
||||
'',
|
||||
].join('\n'),
|
||||
onChunk,
|
||||
jest.fn(),
|
||||
);
|
||||
expect(onChunk.mock.calls).toEqual([['ok']]);
|
||||
});
|
||||
|
||||
it('handles CRLF line endings and "data:" without a space', () => {
|
||||
const onChunk = jest.fn();
|
||||
const noSpace = `data:${JSON.stringify({ choices: [{ delta: { content: 'nospace' } }] })}`;
|
||||
parseSSELines(`${sseLine('crlf')}\r\n${noSpace}\n`, onChunk, jest.fn());
|
||||
expect(onChunk.mock.calls).toEqual([['crlf'], ['nospace']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
it('sends the Authorization header only when an API key is set', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
await testConnection({ ...config, apiKey: 'secret' });
|
||||
await testConnection({ ...config, apiKey: '' });
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('http://192.168.0.10:1234/v1/models');
|
||||
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Bearer secret' });
|
||||
expect(fetchMock.mock.calls[1][1].headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
|
||||
it('returns true on a 2xx response and false otherwise', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ ok: true });
|
||||
await expect(testConnection(config)).resolves.toBe(true);
|
||||
fetchMock.mockResolvedValueOnce({ ok: false });
|
||||
await expect(testConnection(config)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the network call throws', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('Network request failed'));
|
||||
await expect(testConnection(config)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('aborts after 5 seconds and returns false', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
fetchMock.mockImplementation(
|
||||
(_url: string, init: { signal: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
}),
|
||||
);
|
||||
const pending = testConnection(config);
|
||||
await jest.advanceTimersByTimeAsync(5000);
|
||||
await expect(pending).resolves.toBe(false);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not leave the timeout timer running after a failure', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
fetchMock.mockRejectedValue(new TypeError('Network request failed'));
|
||||
await testConnection(config);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModels', () => {
|
||||
it('returns the data array', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ data: [{ id: 'm1' }] }) });
|
||||
await expect(getModels(config)).resolves.toEqual([{ id: 'm1' }]);
|
||||
});
|
||||
|
||||
it('falls back to an empty list when data is missing', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
|
||||
await expect(getModels(config)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('throws with the HTTP status when the response is not ok', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' });
|
||||
await expect(getModels(config)).rejects.toThrow('Failed to fetch models: 401 Unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatCompletion', () => {
|
||||
it('posts a non-streaming request with defaults that options can override', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'x' }) });
|
||||
await chatCompletion(config, 'model-a', [{ role: 'user', content: 'hi' }], { temperature: 0.1 });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('http://192.168.0.10:1234/v1/chat/completions');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
model: 'model-a',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: false,
|
||||
temperature: 0.1,
|
||||
max_tokens: 2048,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws with the HTTP status when the response is not ok', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' });
|
||||
await expect(chatCompletion(config, 'm', [])).rejects.toThrow('Chat completion failed: 500 Server Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatCompletionStream (web, fetch + ReadableStream)', () => {
|
||||
const originalOS = Platform.OS;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
beforeEach(() => {
|
||||
Platform.OS = 'web';
|
||||
});
|
||||
afterEach(() => {
|
||||
Platform.OS = originalOS;
|
||||
});
|
||||
|
||||
const streamOf = (parts: string[]) =>
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
parts.forEach((p) => controller.enqueue(encoder.encode(p)));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
// Starts a stream and resolves once either onDone or onError fired.
|
||||
const run = () => {
|
||||
const onChunk = jest.fn();
|
||||
const onDone = jest.fn();
|
||||
const onError = jest.fn();
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
chatCompletionStream(
|
||||
config,
|
||||
'm',
|
||||
[],
|
||||
onChunk,
|
||||
() => {
|
||||
onDone();
|
||||
resolve();
|
||||
},
|
||||
(e) => {
|
||||
onError(e);
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
return { onChunk, onDone, onError, finished };
|
||||
};
|
||||
|
||||
it('streams chunks split across reads and calls onDone exactly once', async () => {
|
||||
const whole = `${sseLine('Hi')}\n${sseLine(' there')}\ndata: [DONE]\n`;
|
||||
fetchMock.mockResolvedValue({ ok: true, body: streamOf([whole.slice(0, 20), whole.slice(20)]) });
|
||||
|
||||
const { onChunk, onDone, onError, finished } = run();
|
||||
await finished;
|
||||
|
||||
expect(onChunk.mock.calls).toEqual([['Hi'], [' there']]);
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body).stream).toBe(true);
|
||||
});
|
||||
|
||||
it('calls onDone when the stream ends without [DONE]', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: true, body: streamOf([`${sseLine('x')}\n`]) });
|
||||
const { onChunk, onDone, finished } = run();
|
||||
await finished;
|
||||
expect(onChunk).toHaveBeenCalledWith('x');
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports an HTTP error through onError', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' });
|
||||
const { onError, finished } = run();
|
||||
await finished;
|
||||
expect(onError.mock.calls[0][0].message).toBe('Stream failed: 404 Not Found');
|
||||
});
|
||||
|
||||
it('turns a network failure into an actionable connection error', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
const { onError, finished } = run();
|
||||
await finished;
|
||||
expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1');
|
||||
});
|
||||
|
||||
it('does not report an error when the request is aborted', async () => {
|
||||
fetchMock.mockImplementation(
|
||||
(_url: string, init: { signal: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener('abort', () => {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
});
|
||||
}),
|
||||
);
|
||||
const onError = jest.fn();
|
||||
const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
|
||||
handle.abort();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chatCompletionStream (native, XMLHttpRequest)', () => {
|
||||
class FakeXHR {
|
||||
static last: FakeXHR;
|
||||
status = 200;
|
||||
statusText = 'OK';
|
||||
responseText = '';
|
||||
headers: Record<string, string> = {};
|
||||
aborted = false;
|
||||
onprogress: (() => void) | null = null;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
ontimeout: (() => void) | null = null;
|
||||
open = jest.fn();
|
||||
send = jest.fn();
|
||||
constructor() {
|
||||
FakeXHR.last = this;
|
||||
}
|
||||
setRequestHeader(k: string, v: string) {
|
||||
this.headers[k] = v;
|
||||
}
|
||||
abort() {
|
||||
this.aborted = true;
|
||||
}
|
||||
}
|
||||
|
||||
const originalXHR = global.XMLHttpRequest;
|
||||
beforeEach(() => {
|
||||
global.XMLHttpRequest = FakeXHR as unknown as typeof XMLHttpRequest;
|
||||
});
|
||||
afterEach(() => {
|
||||
global.XMLHttpRequest = originalXHR;
|
||||
});
|
||||
|
||||
it('parses progress events incrementally and finishes once', () => {
|
||||
const onChunk = jest.fn();
|
||||
const onDone = jest.fn();
|
||||
chatCompletionStream({ ...config, apiKey: 'k' }, 'm', [], onChunk, onDone, jest.fn());
|
||||
const xhr = FakeXHR.last;
|
||||
|
||||
expect(xhr.open).toHaveBeenCalledWith('POST', 'http://192.168.0.10:1234/v1/chat/completions');
|
||||
expect(xhr.headers.Authorization).toBe('Bearer k');
|
||||
|
||||
const full = `${sseLine('A')}\n${sseLine('B')}\n`;
|
||||
xhr.responseText = full.slice(0, 30);
|
||||
xhr.onprogress?.();
|
||||
xhr.responseText = full;
|
||||
xhr.onprogress?.();
|
||||
xhr.responseText = `${full}data: [DONE]\n`;
|
||||
xhr.onprogress?.();
|
||||
xhr.onload?.();
|
||||
|
||||
expect(onChunk.mock.calls).toEqual([['A'], ['B']]);
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('flushes a final line that has no trailing newline on load', () => {
|
||||
const onChunk = jest.fn();
|
||||
chatCompletionStream(config, 'm', [], onChunk, jest.fn(), jest.fn());
|
||||
const xhr = FakeXHR.last;
|
||||
xhr.responseText = sseLine('tail');
|
||||
xhr.onprogress?.();
|
||||
xhr.onload?.();
|
||||
expect(onChunk.mock.calls).toEqual([['tail']]);
|
||||
});
|
||||
|
||||
it('reports an HTTP error status instead of finishing silently', () => {
|
||||
const onDone = jest.fn();
|
||||
const onError = jest.fn();
|
||||
chatCompletionStream(config, 'm', [], jest.fn(), onDone, onError);
|
||||
const xhr = FakeXHR.last;
|
||||
xhr.status = 401;
|
||||
xhr.statusText = 'Unauthorized';
|
||||
xhr.responseText = '{"error":"bad key"}';
|
||||
xhr.onprogress?.();
|
||||
xhr.onload?.();
|
||||
|
||||
expect(onError.mock.calls[0][0].message).toBe('Stream failed: 401 Unauthorized');
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('turns a network error into an actionable connection error', () => {
|
||||
const onError = jest.fn();
|
||||
chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
|
||||
FakeXHR.last.status = 0;
|
||||
FakeXHR.last.onerror?.();
|
||||
expect(onError.mock.calls[0][0].message).toContain('Could not reach the server at http://192.168.0.10:1234/v1');
|
||||
});
|
||||
|
||||
it('reports a timeout', () => {
|
||||
const onError = jest.fn();
|
||||
chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), onError);
|
||||
FakeXHR.last.ontimeout?.();
|
||||
expect(onError.mock.calls[0][0].message).toBe('Request timed out');
|
||||
});
|
||||
|
||||
it('abort() aborts the request and swallows errors thrown by abort', () => {
|
||||
const handle = chatCompletionStream(config, 'm', [], jest.fn(), jest.fn(), jest.fn());
|
||||
const xhr = FakeXHR.last;
|
||||
handle.abort();
|
||||
expect(xhr.aborted).toBe(true);
|
||||
|
||||
xhr.abort = () => {
|
||||
throw new Error('boom');
|
||||
};
|
||||
expect(() => handle.abort()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { appStorage } from '../store/debouncedStorage';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
|
||||
const state = () => useAppStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStore.setState(useAppStore.getInitialState(), true);
|
||||
});
|
||||
|
||||
describe('messages', () => {
|
||||
it('addMessage appends with a generated id and timestamp, and returns the id', () => {
|
||||
const id = state().addMessage({ role: 'user', content: 'hi' });
|
||||
const [msg] = state().messages;
|
||||
|
||||
expect(msg.id).toBe(id);
|
||||
expect(msg).toMatchObject({ role: 'user', content: 'hi' });
|
||||
expect(typeof msg.timestamp).toBe('number');
|
||||
});
|
||||
|
||||
it('generates distinct ids for messages created in the same millisecond', () => {
|
||||
const ids = new Set(Array.from({ length: 50 }, () => state().addMessage({ role: 'user', content: 'x' })));
|
||||
expect(ids.size).toBe(50);
|
||||
});
|
||||
|
||||
it('appendToMessage and updateMessageContent only touch the target message', () => {
|
||||
const a = state().addMessage({ role: 'assistant', content: 'He' });
|
||||
const b = state().addMessage({ role: 'assistant', content: 'other' });
|
||||
|
||||
state().appendToMessage(a, 'llo');
|
||||
expect(state().messages.map((m) => m.content)).toEqual(['Hello', 'other']);
|
||||
|
||||
state().updateMessageContent(b, 'replaced');
|
||||
expect(state().messages.map((m) => m.content)).toEqual(['Hello', 'replaced']);
|
||||
});
|
||||
|
||||
it('editMessage replaces the content and marks it as edited', () => {
|
||||
const id = state().addMessage({ role: 'user', content: 'old' });
|
||||
state().editMessage(id, 'new');
|
||||
expect(state().messages[0]).toMatchObject({ content: 'new', isEdited: true });
|
||||
});
|
||||
|
||||
it('deleteMessage removes only the given message', () => {
|
||||
const a = state().addMessage({ role: 'user', content: 'a' });
|
||||
state().addMessage({ role: 'assistant', content: 'b' });
|
||||
state().deleteMessage(a);
|
||||
expect(state().messages.map((m) => m.content)).toEqual(['b']);
|
||||
});
|
||||
|
||||
describe('deleteMessagesAfter', () => {
|
||||
it('keeps the given message and drops everything after it', () => {
|
||||
const a = state().addMessage({ role: 'user', content: 'a' });
|
||||
state().addMessage({ role: 'assistant', content: 'b' });
|
||||
state().addMessage({ role: 'user', content: 'c' });
|
||||
state().deleteMessagesAfter(a);
|
||||
expect(state().messages.map((m) => m.content)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('does nothing when the id is unknown', () => {
|
||||
state().addMessage({ role: 'user', content: 'a' });
|
||||
state().deleteMessagesAfter('missing');
|
||||
expect(state().messages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('clearChat empties the conversation and forgets the current session', () => {
|
||||
state().addMessage({ role: 'user', content: 'a' });
|
||||
state().setCurrentChatId('abc');
|
||||
state().clearChat();
|
||||
expect(state().messages).toEqual([]);
|
||||
expect(state().currentChatId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat sessions', () => {
|
||||
it('saveCurrentChat ignores an empty conversation', () => {
|
||||
state().saveCurrentChat();
|
||||
expect(state().chatSessions).toEqual([]);
|
||||
expect(state().currentChatId).toBeNull();
|
||||
});
|
||||
|
||||
it('creates a session titled by the first user message and makes it current', () => {
|
||||
state().addMessage({ role: 'assistant', content: 'welcome' });
|
||||
state().addMessage({ role: 'user', content: 'first question' });
|
||||
state().saveCurrentChat();
|
||||
|
||||
expect(state().chatSessions).toHaveLength(1);
|
||||
const [session] = state().chatSessions;
|
||||
expect(session.title).toBe('first question');
|
||||
expect(session.messages).toHaveLength(2);
|
||||
expect(state().currentChatId).toBe(session.id);
|
||||
});
|
||||
|
||||
it('truncates long titles to 40 characters plus an ellipsis', () => {
|
||||
state().addMessage({ role: 'user', content: 'x'.repeat(60) });
|
||||
state().saveCurrentChat();
|
||||
expect(state().chatSessions[0].title).toBe(`${'x'.repeat(40)}...`);
|
||||
});
|
||||
|
||||
it('falls back to "New Chat" when there is no user message', () => {
|
||||
state().addMessage({ role: 'assistant', content: 'hello' });
|
||||
state().saveCurrentChat();
|
||||
expect(state().chatSessions[0].title).toBe('New Chat');
|
||||
});
|
||||
|
||||
it('updates the existing session instead of creating another one', () => {
|
||||
state().addMessage({ role: 'user', content: 'q' });
|
||||
state().saveCurrentChat();
|
||||
const firstId = state().currentChatId;
|
||||
|
||||
state().addMessage({ role: 'assistant', content: 'a' });
|
||||
state().saveCurrentChat();
|
||||
|
||||
expect(state().chatSessions).toHaveLength(1);
|
||||
expect(state().chatSessions[0].id).toBe(firstId);
|
||||
expect(state().chatSessions[0].messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('only rewrites the session that matches currentChatId', () => {
|
||||
useAppStore.setState({
|
||||
chatSessions: [
|
||||
{ id: '1', title: 'one', updatedAt: 1, messages: [] },
|
||||
{ id: '2', title: 'two', updatedAt: 2, messages: [] },
|
||||
],
|
||||
currentChatId: '2',
|
||||
messages: [{ id: 'm', role: 'user', content: 'changed', timestamp: 3 }],
|
||||
});
|
||||
state().saveCurrentChat();
|
||||
expect(state().chatSessions[0]).toMatchObject({ id: '1', title: 'one' });
|
||||
expect(state().chatSessions[1]).toMatchObject({ id: '2', title: 'changed' });
|
||||
});
|
||||
|
||||
it('loadChat restores the messages of a session, and ignores unknown ids', () => {
|
||||
const messages = [{ id: 'm', role: 'user' as const, content: 'saved', timestamp: 1 }];
|
||||
useAppStore.setState({ chatSessions: [{ id: 's', title: 't', updatedAt: 1, messages }] });
|
||||
|
||||
state().loadChat('missing');
|
||||
expect(state().messages).toEqual([]);
|
||||
|
||||
state().loadChat('s');
|
||||
expect(state().messages).toEqual(messages);
|
||||
expect(state().currentChatId).toBe('s');
|
||||
});
|
||||
|
||||
it('deleteChatSession resets the open conversation only when it deletes the current one', () => {
|
||||
const session = (id: string) => ({ id, title: id, updatedAt: 1, messages: [] });
|
||||
useAppStore.setState({
|
||||
chatSessions: [session('a'), session('b')],
|
||||
currentChatId: 'a',
|
||||
messages: [{ id: 'm', role: 'user', content: 'x', timestamp: 1 }],
|
||||
});
|
||||
|
||||
state().deleteChatSession('b');
|
||||
expect(state().chatSessions.map((s) => s.id)).toEqual(['a']);
|
||||
expect(state().messages).toHaveLength(1);
|
||||
|
||||
state().deleteChatSession('a');
|
||||
expect(state().chatSessions).toEqual([]);
|
||||
expect(state().messages).toEqual([]);
|
||||
expect(state().currentChatId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('persists settings, the selected model and chats but not transient connection or streaming state', async () => {
|
||||
useAppStore.setState({
|
||||
theme: 'dark',
|
||||
serverConfig: { host: 'lan.local', port: 4321, apiKey: 'k' },
|
||||
isConnected: true,
|
||||
isStreaming: true,
|
||||
activeModelId: 'model',
|
||||
availableModels: [{ id: 'model', object: 'model', owned_by: 'x' }],
|
||||
editingMessageId: 'e',
|
||||
});
|
||||
state().addMessage({ role: 'user', content: 'persist me' });
|
||||
|
||||
await appStorage.flush();
|
||||
const raw = await AsyncStorage.getItem('localaiapp-storage');
|
||||
const persisted = JSON.parse(raw as string).state;
|
||||
|
||||
expect(Object.keys(persisted).sort()).toEqual(
|
||||
['activeModelId', 'chatSessions', 'currentChatId', 'messages', 'serverConfig', 'theme'],
|
||||
);
|
||||
expect(persisted.serverConfig).toEqual({ host: 'lan.local', port: 4321, apiKey: 'k' });
|
||||
expect(persisted.activeModelId).toBe('model');
|
||||
expect(persisted.messages[0].content).toBe('persist me');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
import { useAutoConnect } from '../hooks/useAutoConnect';
|
||||
import * as api from '../services/lmStudioApi';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
|
||||
jest.mock('../services/lmStudioApi');
|
||||
|
||||
const mockApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
useAppStore.setState(useAppStore.getInitialState(), true);
|
||||
});
|
||||
|
||||
describe('useAutoConnect', () => {
|
||||
it('loads the models once on mount and marks the server connected', async () => {
|
||||
mockApi.getModels.mockResolvedValue([{ id: 'm1', object: 'model', owned_by: 'x' }]);
|
||||
renderHook(() => useAutoConnect());
|
||||
|
||||
await act(async () => {});
|
||||
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
|
||||
expect(useAppStore.getState().isConnected).toBe(true);
|
||||
expect(useAppStore.getState().activeModelId).toBe('m1');
|
||||
});
|
||||
|
||||
it('leaves the app offline when the server does not answer', async () => {
|
||||
mockApi.getModels.mockRejectedValue(new Error('down'));
|
||||
renderHook(() => useAutoConnect());
|
||||
|
||||
await act(async () => {});
|
||||
expect(useAppStore.getState().isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('does not check again on re-render', async () => {
|
||||
mockApi.getModels.mockResolvedValue([]);
|
||||
const { rerender } = renderHook(() => useAutoConnect());
|
||||
await act(async () => {});
|
||||
rerender({});
|
||||
await act(async () => {});
|
||||
expect(mockApi.getModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
import { useLMStudio } from '../hooks/useLMStudio';
|
||||
import * as api from '../services/lmStudioApi';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
|
||||
jest.mock('../services/lmStudioApi');
|
||||
|
||||
const mockApi = api as jest.Mocked<typeof api>;
|
||||
const state = () => useAppStore.getState();
|
||||
|
||||
type StreamArgs = Parameters<typeof api.chatCompletionStream>;
|
||||
const lastStreamCall = () => mockApi.chatCompletionStream.mock.calls.at(-1) as StreamArgs;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
useAppStore.setState(useAppStore.getInitialState(), true);
|
||||
useAppStore.setState({ activeModelId: 'model-a' });
|
||||
mockApi.chatCompletionStream.mockReturnValue({ abort: jest.fn() });
|
||||
});
|
||||
|
||||
describe('checkConnection', () => {
|
||||
it('stores the result of the connection test', async () => {
|
||||
mockApi.testConnection.mockResolvedValue(true);
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.checkConnection()).resolves.toBe(true);
|
||||
});
|
||||
expect(state().isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchModels', () => {
|
||||
const models = [
|
||||
{ id: 'first', object: 'model', owned_by: 'x' },
|
||||
{ id: 'second', object: 'model', owned_by: 'x' },
|
||||
];
|
||||
|
||||
it('stores the models, marks the server connected and selects the first model if none is active', async () => {
|
||||
useAppStore.setState({ activeModelId: '' });
|
||||
mockApi.getModels.mockResolvedValue(models);
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchModels();
|
||||
});
|
||||
expect(state().availableModels).toEqual(models);
|
||||
expect(state().isConnected).toBe(true);
|
||||
expect(state().activeModelId).toBe('first');
|
||||
});
|
||||
|
||||
it('keeps the model that was already selected', async () => {
|
||||
mockApi.getModels.mockResolvedValue(models);
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchModels();
|
||||
});
|
||||
expect(state().activeModelId).toBe('model-a');
|
||||
});
|
||||
|
||||
it('does not select anything when the server has no models', async () => {
|
||||
useAppStore.setState({ activeModelId: '' });
|
||||
mockApi.getModels.mockResolvedValue([]);
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchModels();
|
||||
});
|
||||
expect(state().activeModelId).toBe('');
|
||||
});
|
||||
|
||||
it('marks the server offline and clears the list when the request fails', async () => {
|
||||
useAppStore.setState({ isConnected: true, availableModels: models });
|
||||
mockApi.getModels.mockRejectedValue(new Error('down'));
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
|
||||
let returned: unknown;
|
||||
await act(async () => {
|
||||
returned = await result.current.fetchModels();
|
||||
});
|
||||
expect(returned).toEqual([]);
|
||||
expect(state().isConnected).toBe(false);
|
||||
expect(state().availableModels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessage', () => {
|
||||
it('streams an answer for the conversation, prefixed by the system prompt', async () => {
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('hello');
|
||||
});
|
||||
|
||||
const [, modelId, apiMessages, onChunk, onDone] = lastStreamCall();
|
||||
expect(modelId).toBe('model-a');
|
||||
expect(apiMessages[0].role).toBe('system');
|
||||
expect(apiMessages.slice(1)).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
expect(state().isStreaming).toBe(true);
|
||||
|
||||
act(() => {
|
||||
onChunk('Hi');
|
||||
onChunk(' there');
|
||||
onDone();
|
||||
});
|
||||
expect(state().messages.map((m) => `${m.role}:${m.content}`)).toEqual(['user:hello', 'assistant:Hi there']);
|
||||
expect(state().isStreaming).toBe(false);
|
||||
expect(state().chatSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('asks for a model instead of calling the server when none is selected', async () => {
|
||||
useAppStore.setState({ activeModelId: '' });
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('hello');
|
||||
});
|
||||
|
||||
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
|
||||
expect(state().messages[1].content).toContain('No model selected');
|
||||
expect(state().isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores new messages while an answer is streaming', async () => {
|
||||
useAppStore.setState({ isStreaming: true });
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('again');
|
||||
});
|
||||
|
||||
expect(state().messages).toEqual([]);
|
||||
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes the error into the assistant message and stops streaming when the stream fails', async () => {
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('hello');
|
||||
});
|
||||
|
||||
const onError = lastStreamCall()[5];
|
||||
act(() => {
|
||||
onError(new Error('Could not reach the server'));
|
||||
});
|
||||
expect(state().messages[1].content).toContain('Error: Could not reach the server');
|
||||
expect(state().isStreaming).toBe(false);
|
||||
expect(state().chatSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports a synchronous failure to start the stream', async () => {
|
||||
mockApi.chatCompletionStream.mockImplementation(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('hello');
|
||||
});
|
||||
|
||||
expect(state().messages[1].content).toContain('Connection failed: boom');
|
||||
expect(state().isStreaming).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regenerateResponse', () => {
|
||||
it('drops everything after the last user message and asks again', async () => {
|
||||
state().addMessage({ role: 'user', content: 'q1' });
|
||||
state().addMessage({ role: 'assistant', content: 'a1' });
|
||||
state().addMessage({ role: 'user', content: 'q2' });
|
||||
state().addMessage({ role: 'assistant', content: 'a2' });
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.regenerateResponse();
|
||||
});
|
||||
const apiMessages = lastStreamCall()[2];
|
||||
expect(apiMessages.slice(1).map((m) => m.content)).toEqual(['q1', 'a1', 'q2']);
|
||||
expect(state().messages.map((m) => m.content)).toEqual(['q1', 'a1', 'q2', '']);
|
||||
expect(state().isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the current answer and asks for a model when none is selected', async () => {
|
||||
state().addMessage({ role: 'user', content: 'q' });
|
||||
state().addMessage({ role: 'assistant', content: 'a' });
|
||||
useAppStore.setState({ activeModelId: '' });
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.regenerateResponse();
|
||||
});
|
||||
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
|
||||
expect(state().messages.map((m) => m.content)).toEqual([
|
||||
'q',
|
||||
'a',
|
||||
expect.stringContaining('No model selected'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing without a user message or while streaming', async () => {
|
||||
const { result, rerender } = renderHook(() => useLMStudio());
|
||||
await act(async () => {
|
||||
await result.current.regenerateResponse();
|
||||
});
|
||||
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
|
||||
expect(state().messages).toEqual([]);
|
||||
|
||||
act(() => {
|
||||
state().addMessage({ role: 'user', content: 'q' });
|
||||
});
|
||||
act(() => {
|
||||
useAppStore.setState({ activeModelId: 'model-a', isStreaming: true });
|
||||
});
|
||||
rerender({});
|
||||
await act(async () => {
|
||||
await result.current.regenerateResponse();
|
||||
});
|
||||
expect(mockApi.chatCompletionStream).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopStreaming', () => {
|
||||
it('aborts the request, stops streaming and keeps the partial answer in History', async () => {
|
||||
const abort = jest.fn();
|
||||
mockApi.chatCompletionStream.mockReturnValue({ abort });
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('hello');
|
||||
});
|
||||
act(() => {
|
||||
lastStreamCall()[3]('partial');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.stopStreaming();
|
||||
});
|
||||
expect(abort).toHaveBeenCalledTimes(1);
|
||||
expect(state().isStreaming).toBe(false);
|
||||
expect(state().chatSessions[0].messages.at(-1)?.content).toBe('partial');
|
||||
});
|
||||
|
||||
it('is a no-op when nothing is streaming', () => {
|
||||
const { result } = renderHook(() => useLMStudio());
|
||||
act(() => {
|
||||
result.current.stopStreaming();
|
||||
});
|
||||
expect(state().chatSessions).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ChatMessage } from '../store/useAppStore';
|
||||
import { findLastAssistantId, shouldShowTypingIndicator } from '../utils/chatSelectors';
|
||||
import { buildServerConfig, isConfigChanged } from '../utils/serverConfig';
|
||||
import { isDarkTheme } from '../utils/theme';
|
||||
|
||||
const msg = (id: string, role: ChatMessage['role'], content = 'x'): ChatMessage => ({
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
timestamp: 0,
|
||||
});
|
||||
|
||||
describe('findLastAssistantId', () => {
|
||||
it('returns the most recent assistant message', () => {
|
||||
expect(findLastAssistantId([msg('1', 'assistant'), msg('2', 'user'), msg('3', 'assistant')])).toBe('3');
|
||||
});
|
||||
|
||||
it('returns null when there is no assistant message', () => {
|
||||
expect(findLastAssistantId([msg('1', 'user')])).toBeNull();
|
||||
expect(findLastAssistantId([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldShowTypingIndicator', () => {
|
||||
it('is true only while streaming an assistant message with no text yet', () => {
|
||||
expect(shouldShowTypingIndicator([msg('1', 'user'), msg('2', 'assistant', '')], true)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when not streaming, when text arrived, when the last message is the user, or when empty', () => {
|
||||
expect(shouldShowTypingIndicator([msg('2', 'assistant', '')], false)).toBe(false);
|
||||
expect(shouldShowTypingIndicator([msg('2', 'assistant', 'hi')], true)).toBe(false);
|
||||
expect(shouldShowTypingIndicator([msg('1', 'user', '')], true)).toBe(false);
|
||||
expect(shouldShowTypingIndicator([], true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildServerConfig', () => {
|
||||
it('trims the host and API key and parses the port', () => {
|
||||
expect(buildServerConfig({ host: ' 10.0.0.1 ', port: '8080', apiKey: ' k ' })).toEqual({
|
||||
host: '10.0.0.1',
|
||||
port: 8080,
|
||||
apiKey: 'k',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the defaults for blank host, invalid port and blank key', () => {
|
||||
expect(buildServerConfig({ host: '', port: 'abc', apiKey: ' ' })).toEqual({
|
||||
host: 'localhost',
|
||||
port: 1234,
|
||||
apiKey: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConfigChanged', () => {
|
||||
const saved = { host: 'lan', port: 1234, apiKey: 'k' };
|
||||
|
||||
it('is false when the form matches the saved config', () => {
|
||||
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: 'k' }, saved)).toBe(false);
|
||||
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, { host: 'lan', port: 1234 })).toBe(false);
|
||||
});
|
||||
|
||||
it('is true when any single field differs', () => {
|
||||
expect(isConfigChanged({ host: 'other', port: '1234', apiKey: 'k' }, saved)).toBe(true);
|
||||
expect(isConfigChanged({ host: 'lan', port: '99', apiKey: 'k' }, saved)).toBe(true);
|
||||
expect(isConfigChanged({ host: 'lan', port: '1234', apiKey: '' }, saved)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDarkTheme', () => {
|
||||
it('honours an explicit preference and falls back to the system scheme', () => {
|
||||
expect(isDarkTheme('dark', 'light')).toBe(true);
|
||||
expect(isDarkTheme('light', 'dark')).toBe(false);
|
||||
expect(isDarkTheme('system', 'dark')).toBe(true);
|
||||
expect(isDarkTheme('system', 'light')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ModelSelector } from '@/components/chat/ModelSelector';
|
||||
import { useAutoConnect } from '@/hooks/useAutoConnect';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { Stack, useRouter } from 'expo-router';
|
||||
@@ -10,6 +11,7 @@ export default function ChatLayout() {
|
||||
const router = useRouter();
|
||||
const clearChat = useAppStore(state => state.clearChat);
|
||||
const colors = useThemeColor();
|
||||
useAutoConnect();
|
||||
|
||||
const isLargeScreen = width > 768;
|
||||
const containerStyle = [
|
||||
@@ -36,6 +38,8 @@ export default function ChatLayout() {
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/chat/history')}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Chat history"
|
||||
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
@@ -43,6 +47,8 @@ export default function ChatLayout() {
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={clearChat}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New chat"
|
||||
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
@@ -53,6 +59,8 @@ export default function ChatLayout() {
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/settings')}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Settings"
|
||||
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
|
||||
+10
-1
@@ -20,7 +20,12 @@ export default function HistoryScreen() {
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.backgroundColor }]}>
|
||||
<View style={[styles.header, { borderBottomColor: colors.border, backgroundColor: colors.backgroundColor }]}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={styles.backButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Back"
|
||||
>
|
||||
<ArrowLeft size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Chat History</Text>
|
||||
@@ -43,6 +48,8 @@ export default function HistoryScreen() {
|
||||
{ backgroundColor: colors.cardBackground, borderColor: colors.border }
|
||||
]}
|
||||
onPress={() => handleLoadChat(item.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open chat: ${item.title}`}
|
||||
>
|
||||
<View style={styles.chatInfo}>
|
||||
<Text style={[styles.chatTitle, { color: colors.text }]} numberOfLines={1}>
|
||||
@@ -55,6 +62,8 @@ export default function HistoryScreen() {
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={() => deleteChatSession(item.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Delete chat: ${item.title}`}
|
||||
>
|
||||
<Trash2 size={20} color={colors.errorText} />
|
||||
</TouchableOpacity>
|
||||
|
||||
+17
-176
@@ -1,20 +1,12 @@
|
||||
import { ChatInput } from '@/components/chat/ChatInput';
|
||||
import { MessageBubble } from '@/components/chat/MessageBubble';
|
||||
import { TypingIndicator } from '@/components/chat/TypingIndicator';
|
||||
import { EmptyState } from '@/components/chat/EmptyState';
|
||||
import { MessageList } from '@/components/chat/MessageList';
|
||||
import { OfflineBanner } from '@/components/chat/OfflineBanner';
|
||||
import { useLMStudio } from '@/hooks/useLMStudio';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { MessageSquare, Sparkles } from 'lucide-react-native';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { FlatList, KeyboardAvoidingView, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
|
||||
const PROMPT_SUGGESTIONS = [
|
||||
'Explain recursion in simple terms',
|
||||
'Write a short poem about coding',
|
||||
'What are the best practices for React Native?',
|
||||
'Help me debug a JavaScript error',
|
||||
];
|
||||
import React, { useCallback } from 'react';
|
||||
import { KeyboardAvoidingView, Platform, StyleSheet } from 'react-native';
|
||||
|
||||
export default function ChatScreen() {
|
||||
const colors = useThemeColor(); // Dynamic theme
|
||||
@@ -25,7 +17,6 @@ export default function ChatScreen() {
|
||||
const deleteMessage = useAppStore(state => state.deleteMessage);
|
||||
const editMessage = useAppStore(state => state.editMessage);
|
||||
const { sendMessage, stopStreaming, regenerateResponse, isStreaming, isConnected } = useLMStudio();
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
|
||||
// Find the editing message content
|
||||
const editingMessage = editingMessageId
|
||||
@@ -37,122 +28,35 @@ export default function ChatScreen() {
|
||||
editMessage(editingMessageId, text);
|
||||
deleteMessagesAfter(editingMessageId);
|
||||
setEditingMessageId(null);
|
||||
|
||||
setTimeout(() => {
|
||||
const store = useAppStore.getState();
|
||||
const msgs = store.messages;
|
||||
// Add system prompt before mapped messages
|
||||
const { SYSTEM_PROMPT } = require('@/constants/SystemPrompt');
|
||||
const apiMsgs = [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
...msgs.map(m => ({ role: m.role, content: m.content }))
|
||||
];
|
||||
|
||||
const modelId = store.activeModelId;
|
||||
if (!modelId) return;
|
||||
|
||||
const assistantId = store.addMessage({ role: 'assistant', content: '' });
|
||||
store.setIsStreaming(true);
|
||||
|
||||
const { chatCompletionStream } = require('@/services/lmStudioApi');
|
||||
chatCompletionStream(
|
||||
store.serverConfig,
|
||||
modelId,
|
||||
apiMsgs,
|
||||
(chunk: string) => store.appendToMessage(assistantId, chunk),
|
||||
() => { store.setIsStreaming(false); },
|
||||
(error: Error) => {
|
||||
store.appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
|
||||
store.setIsStreaming(false);
|
||||
},
|
||||
);
|
||||
}, 100);
|
||||
// The edited message is now the last one, so regenerating answers it.
|
||||
regenerateResponse();
|
||||
} else {
|
||||
sendMessage(text);
|
||||
}
|
||||
}, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, sendMessage]);
|
||||
}, [editingMessageId, editingMessage, editMessage, deleteMessagesAfter, setEditingMessageId, regenerateResponse, sendMessage]);
|
||||
|
||||
const handleEdit = useCallback((id: string) => setEditingMessageId(id), [setEditingMessageId]);
|
||||
const handleCancelEdit = useCallback(() => setEditingMessageId(null), [setEditingMessageId]);
|
||||
const handleDelete = useCallback((id: string) => deleteMessage(id), [deleteMessage]);
|
||||
|
||||
const lastAssistantId = messages.length > 0
|
||||
? [...messages].reverse().find(m => m.role === 'assistant')?.id
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0 && flatListRef.current) {
|
||||
setTimeout(() => {
|
||||
flatListRef.current?.scrollToEnd({ animated: !isStreaming });
|
||||
}, 100);
|
||||
}
|
||||
}, [messages.length, messages[messages.length - 1]?.content?.length, isStreaming]);
|
||||
|
||||
const showTypingIndicator = isStreaming &&
|
||||
messages.length > 0 &&
|
||||
messages[messages.length - 1]?.role === 'assistant' &&
|
||||
messages[messages.length - 1]?.content === '';
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.container, { backgroundColor: colors.backgroundColor }]}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
|
||||
>
|
||||
{!isConnected && (
|
||||
<View style={[styles.banner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.bannerBg }]}>
|
||||
<Text style={[styles.bannerText, { color: colors.bannerText }]}>
|
||||
⚠️ Server offline. Check Settings → Server Connection.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{!isConnected && <OfflineBanner />}
|
||||
|
||||
{messages.length === 0 ? (
|
||||
<View style={[styles.emptyState, { backgroundColor: colors.backgroundColor }]}>
|
||||
<View style={[styles.emptyIconWrap, { backgroundColor: colors.cardBackground, shadowColor: colors.shadow }]}>
|
||||
<MessageSquare size={40} color={colors.textMuted} />
|
||||
</View>
|
||||
<Text style={[styles.emptyTitle, { color: colors.text }]}>Start a conversation</Text>
|
||||
<Text style={[styles.emptySubtitle, { color: colors.textMuted }]}>
|
||||
Send a message to begin chatting with your local AI model.
|
||||
</Text>
|
||||
|
||||
<View style={styles.suggestionsWrap}>
|
||||
{PROMPT_SUGGESTIONS.map((prompt, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[styles.suggestionChip, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}
|
||||
onPress={() => sendMessage(prompt)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Sparkles size={14} color={colors.textMuted} />
|
||||
<Text style={[styles.suggestionText, { color: colors.text }]} numberOfLines={1}>{prompt}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<EmptyState onSelectPrompt={sendMessage} />
|
||||
) : (
|
||||
<>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item, index }) => (
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(index > messages.length - 4 ? 100 : 0)}>
|
||||
<MessageBubble
|
||||
message={item}
|
||||
onEdit={() => handleEdit(item.id)}
|
||||
onRegenerate={regenerateResponse}
|
||||
onDelete={() => handleDelete(item.id)}
|
||||
isStreaming={isStreaming}
|
||||
isLastAssistant={item.id === lastAssistantId}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
{showTypingIndicator && <TypingIndicator />}
|
||||
</>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onRegenerate={regenerateResponse}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChatInput
|
||||
@@ -170,67 +74,4 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
paddingTop: 16,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
banner: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
bannerText: {
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
emptyIconWrap: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 20,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 10,
|
||||
elevation: 2,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
marginBottom: 32,
|
||||
},
|
||||
suggestionsWrap: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
gap: 12,
|
||||
},
|
||||
suggestionChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
suggestionText: {
|
||||
fontSize: 15,
|
||||
flex: 1,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
+8
-338
@@ -1,76 +1,14 @@
|
||||
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
|
||||
import { useLMStudio } from '@/hooks/useLMStudio';
|
||||
import { AboutCard } from '@/components/settings/AboutCard';
|
||||
import { AppearanceCard } from '@/components/settings/AppearanceCard';
|
||||
import { ServerConnectionCard } from '@/components/settings/ServerConnectionCard';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import * as api from '@/services/lmStudioApi';
|
||||
import { ServerConfig } from '@/services/types';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { Info, MonitorCog, Moon, Network } from 'lucide-react-native';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { width } = useWindowDimensions();
|
||||
const colors = useThemeColor(); // Get current theme colors
|
||||
|
||||
// Global State
|
||||
const themePreference = useAppStore(state => state.theme);
|
||||
const setTheme = useAppStore(state => state.setTheme);
|
||||
const serverConfig = useAppStore(state => state.serverConfig);
|
||||
const setServerConfig = useAppStore(state => state.setServerConfig);
|
||||
const isConnected = useAppStore(state => state.isConnected);
|
||||
const setIsConnected = useAppStore(state => state.setIsConnected);
|
||||
const { fetchModels } = useLMStudio();
|
||||
|
||||
// Local State
|
||||
const [host, setHost] = useState(serverConfig.host);
|
||||
const [port, setPort] = useState(serverConfig.port.toString());
|
||||
const [apiKey, setApiKey] = useState(serverConfig.apiKey ?? '');
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<boolean | null>(null);
|
||||
|
||||
const isLargeScreen = width > 768;
|
||||
const isDarkMode = themePreference === 'dark' || (themePreference === 'system' && colors.backgroundColor === '#09090B'); // Hacky check but robust given our Colors.ts
|
||||
const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark');
|
||||
|
||||
const currentConfig: ServerConfig = {
|
||||
host: host.trim() || 'localhost',
|
||||
port: parseInt(port, 10) || 1234,
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
};
|
||||
|
||||
const handleSaveConfig = () => {
|
||||
setServerConfig(currentConfig);
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
handleSaveConfig();
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
const ok = await api.testConnection(currentConfig);
|
||||
setTestResult(ok);
|
||||
setIsConnected(ok);
|
||||
if (ok) {
|
||||
await fetchModels();
|
||||
}
|
||||
setIsTesting(false);
|
||||
};
|
||||
|
||||
const configChanged =
|
||||
host !== serverConfig.host ||
|
||||
port !== serverConfig.port.toString() ||
|
||||
apiKey !== (serverConfig.apiKey ?? '');
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
@@ -80,151 +18,9 @@ export default function SettingsScreen() {
|
||||
isLargeScreen && styles.desktopContent,
|
||||
]}
|
||||
>
|
||||
{/* Server Connection Section */}
|
||||
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
|
||||
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>SERVER CONNECTION</Text>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardHeaderTitle}>
|
||||
<Network size={20} color={colors.text} style={styles.headerIcon} />
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>LM Studio API</Text>
|
||||
</View>
|
||||
<ConnectionBadge isConnected={isConnected} />
|
||||
</View>
|
||||
|
||||
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: colors.textMuted }]}>Host IP / Address</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
|
||||
]}
|
||||
value={host}
|
||||
onChangeText={setHost}
|
||||
placeholder="localhost"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: colors.textMuted }]}>Port Number</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
|
||||
]}
|
||||
value={port}
|
||||
onChangeText={setPort}
|
||||
placeholder="1234"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: colors.textMuted }]}>API Key (optional)</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text }
|
||||
]}
|
||||
value={apiKey}
|
||||
onChangeText={setApiKey}
|
||||
placeholder="Leave empty for local"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonRow}>
|
||||
{configChanged && (
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, { borderColor: colors.border, backgroundColor: colors.cardBackground }]}
|
||||
onPress={handleSaveConfig}
|
||||
>
|
||||
<Text style={[styles.saveButtonText, { color: colors.text }]}>Save Changes</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.testButton,
|
||||
{ backgroundColor: colors.tint },
|
||||
isTesting && styles.testButtonDisabled
|
||||
]}
|
||||
onPress={handleTestConnection}
|
||||
disabled={isTesting}
|
||||
>
|
||||
{isTesting ? (
|
||||
<ActivityIndicator size="small" color={colors.backgroundColor} />
|
||||
) : (
|
||||
<Text style={[styles.testButtonText, { color: colors.backgroundColor }]}>Test Connection</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{testResult !== null && (
|
||||
<View style={[
|
||||
styles.resultBox,
|
||||
{ backgroundColor: testResult ? colors.successBg : colors.errorBg }
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.resultText,
|
||||
{ color: testResult ? colors.successText : colors.errorText }
|
||||
]}>
|
||||
{testResult
|
||||
? 'Connection successful. API is ready.'
|
||||
: 'Connection failed. Verify host and port.'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Appearance Section */}
|
||||
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
|
||||
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>APPEARANCE</Text>
|
||||
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowIconWrap}>
|
||||
<Moon size={20} color={colors.text} />
|
||||
</View>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={[styles.rowTitle, { color: colors.text }]}>Dark Mode</Text>
|
||||
<Text style={[styles.rowSubtitle, { color: colors.textMuted }]}>Toggle sleek dark theme</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isDarkMode}
|
||||
onValueChange={toggleTheme}
|
||||
trackColor={{ false: '#E2E8F0', true: colors.tint }}
|
||||
thumbColor={'#FFFFFF'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* About Section */}
|
||||
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
|
||||
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>ABOUT</Text>
|
||||
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
|
||||
<View style={styles.rowGroup}>
|
||||
<Info size={18} color={colors.textMuted} style={styles.infoIcon} />
|
||||
<Text style={[styles.infoText, { color: colors.text }]}>LocalMind - Cross Platform Version</Text>
|
||||
</View>
|
||||
<View style={[styles.rowGroup, { marginTop: 8 }]}>
|
||||
<MonitorCog size={18} color={colors.textMuted} style={styles.infoIcon} />
|
||||
<Text style={[styles.infoText, { color: colors.textMuted, fontSize: 13 }]}>
|
||||
Connected via OpenAPI `/v1/chat/completions` specs
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<ServerConnectionCard />
|
||||
<AppearanceCard />
|
||||
<AboutCard />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -241,130 +37,4 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
paddingTop: 40,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
desktopSection: {
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginLeft: 16,
|
||||
marginBottom: 8,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
},
|
||||
cardHeaderTitle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
marginBottom: 16,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
textInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
saveButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
testButton: {
|
||||
flex: 2,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
testButtonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
testButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
resultBox: {
|
||||
marginTop: 16,
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
},
|
||||
resultText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 4,
|
||||
},
|
||||
rowIconWrap: {
|
||||
marginRight: 12,
|
||||
},
|
||||
rowText: {
|
||||
flex: 1,
|
||||
},
|
||||
rowTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
rowSubtitle: {
|
||||
fontSize: 14,
|
||||
},
|
||||
rowGroup: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoIcon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
});
|
||||
@@ -65,7 +65,12 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
|
||||
{isEditing && (
|
||||
<View style={[styles.editBanner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.border }]}>
|
||||
<Text style={[styles.editBannerText, { color: colors.bannerText }]}>Editing message</Text>
|
||||
<TouchableOpacity onPress={handleCancelEdit} style={styles.editCancelBtn}>
|
||||
<TouchableOpacity
|
||||
onPress={handleCancelEdit}
|
||||
style={styles.editCancelBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel editing"
|
||||
>
|
||||
<X size={16} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -80,6 +85,7 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="Send a message..."
|
||||
accessibilityLabel="Message"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
@@ -94,6 +100,8 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
|
||||
style={[styles.stopButton, { backgroundColor: colors.errorText }]}
|
||||
onPress={handleStop}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Stop generating"
|
||||
>
|
||||
<Square size={16} color={colors.backgroundColor} />
|
||||
</TouchableOpacity>
|
||||
@@ -106,6 +114,9 @@ export function ChatInput({ onSend, onStop, isLoading, editingText, onCancelEdit
|
||||
onPress={handleSend}
|
||||
disabled={!text.trim()}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Send message"
|
||||
accessibilityState={{ disabled: !text.trim() }}
|
||||
>
|
||||
<Send size={18} color={text.trim() ? colors.backgroundColor : colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Check, Copy } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
interface CodeBlockProps {
|
||||
language: string;
|
||||
content: string;
|
||||
copied: boolean;
|
||||
onCopy: (text: string) => void;
|
||||
}
|
||||
|
||||
export function CodeBlock({ language, content, copied, onCopy }: CodeBlockProps) {
|
||||
const CopyIcon = copied ? Check : Copy;
|
||||
|
||||
return (
|
||||
<View style={styles.codeBlockWrapper}>
|
||||
<View style={styles.codeBlockHeader}>
|
||||
<Text style={styles.codeBlockLanguage}>{language}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => onCopy(content)}
|
||||
style={styles.copyButton}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<CopyIcon size={14} color="#A0A0A0" />
|
||||
<Text style={styles.copyText}>{copied ? 'Copied!' : 'Copy'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
|
||||
<Text style={styles.codeText}>{content}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
codeBlockWrapper: {
|
||||
backgroundColor: '#1E1E1E',
|
||||
borderRadius: 8,
|
||||
marginVertical: 8,
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
},
|
||||
codeBlockHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#2D2D2D',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#404040',
|
||||
},
|
||||
codeBlockLanguage: {
|
||||
color: '#A0A0A0',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
textTransform: 'lowercase',
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: 4,
|
||||
},
|
||||
copyText: {
|
||||
color: '#A0A0A0',
|
||||
fontSize: 12,
|
||||
},
|
||||
codeScrollView: {
|
||||
padding: 12,
|
||||
},
|
||||
codeText: {
|
||||
color: '#D4D4D4',
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { MessageSquare, Sparkles } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
const PROMPT_SUGGESTIONS = [
|
||||
'Explain recursion in simple terms',
|
||||
'Write a short poem about coding',
|
||||
'What are the best practices for React Native?',
|
||||
'Help me debug a JavaScript error',
|
||||
];
|
||||
|
||||
interface EmptyStateProps {
|
||||
onSelectPrompt: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export function EmptyState({ onSelectPrompt }: EmptyStateProps) {
|
||||
const colors = useThemeColor();
|
||||
|
||||
return (
|
||||
<View style={[styles.emptyState, { backgroundColor: colors.backgroundColor }]}>
|
||||
<View style={[styles.emptyIconWrap, { backgroundColor: colors.cardBackground, shadowColor: colors.shadow }]}>
|
||||
<MessageSquare size={40} color={colors.textMuted} />
|
||||
</View>
|
||||
<Text style={[styles.emptyTitle, { color: colors.text }]}>Start a conversation</Text>
|
||||
<Text style={[styles.emptySubtitle, { color: colors.textMuted }]}>
|
||||
Send a message to begin chatting with your local AI model.
|
||||
</Text>
|
||||
|
||||
<View style={styles.suggestionsWrap}>
|
||||
{PROMPT_SUGGESTIONS.map((prompt) => (
|
||||
<TouchableOpacity
|
||||
key={prompt}
|
||||
style={[styles.suggestionChip, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}
|
||||
onPress={() => onSelectPrompt(prompt)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={prompt}
|
||||
>
|
||||
<Sparkles size={14} color={colors.textMuted} />
|
||||
<Text style={[styles.suggestionText, { color: colors.text }]} numberOfLines={1}>{prompt}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 32,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
emptyIconWrap: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 20,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 10,
|
||||
elevation: 2,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
marginBottom: 32,
|
||||
},
|
||||
suggestionsWrap: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
gap: 12,
|
||||
},
|
||||
suggestionChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
},
|
||||
suggestionText: {
|
||||
fontSize: 15,
|
||||
flex: 1,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@@ -50,24 +50,24 @@ export function MessageActions({
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6}>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleCopy} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Copy message">
|
||||
<Copy size={16} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{isUser && onEdit && (
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6}>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleEdit} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Edit message">
|
||||
<Pencil size={16} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{!isUser && onRegenerate && (
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6}>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleRegenerate} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Regenerate response">
|
||||
<RefreshCw size={16} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6}>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleDelete} activeOpacity={0.6} accessibilityRole="button" accessibilityLabel="Delete message">
|
||||
<Trash2 size={16} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { ChatMessage } from '@/store/useAppStore';
|
||||
import Markdown from '@ronradtke/react-native-markdown-display';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Bot, Check, Copy, User } from 'lucide-react-native';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native';
|
||||
import { Bot, User } from 'lucide-react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
|
||||
import { MessageActions } from './MessageActions';
|
||||
import { MessageContent } from './MessageContent';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
@@ -16,6 +15,58 @@ interface MessageBubbleProps {
|
||||
isLastAssistant?: boolean;
|
||||
}
|
||||
|
||||
function Avatar({ isUser }: { isUser: boolean }) {
|
||||
const colors = useThemeColor();
|
||||
const Icon = isUser ? User : Bot;
|
||||
|
||||
return (
|
||||
<View style={[styles.avatar, { backgroundColor: isUser ? colors.tint : colors.border }]}>
|
||||
<Icon size={16} color={isUser ? colors.backgroundColor : colors.text} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BubbleRow({ message, showingStreaming }: { message: ChatMessage; showingStreaming: boolean }) {
|
||||
const { width } = useWindowDimensions();
|
||||
const colors = useThemeColor();
|
||||
const isUser = message.role === 'user';
|
||||
const maxWidth = width > 768 ? '70%' : '85%';
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isUser ? styles.containerUser : styles.containerAssistant]}>
|
||||
{!isUser && <Avatar isUser={false} />}
|
||||
|
||||
<View style={[
|
||||
styles.bubble,
|
||||
isUser ? styles.bubbleUser : styles.bubbleAssistant,
|
||||
{ maxWidth },
|
||||
{ backgroundColor: isUser ? colors.messageUserBg : colors.messageAssistantBg },
|
||||
!isUser && { borderWidth: 1, borderColor: colors.border }
|
||||
]}>
|
||||
<MessageContent message={message} showingStreaming={showingStreaming} />
|
||||
</View>
|
||||
|
||||
{isUser && <Avatar isUser />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Timestamp + edited badge. */
|
||||
function BubbleMeta({ message }: { message: ChatMessage }) {
|
||||
const colors = useThemeColor();
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
return (
|
||||
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
|
||||
{!isUser && <View style={{ width: 32 }} />}
|
||||
<Text style={[styles.timestamp, { color: colors.textMuted }]}>
|
||||
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{message.isEdited ? ' · edited' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
message,
|
||||
onEdit,
|
||||
@@ -24,217 +75,21 @@ export function MessageBubble({
|
||||
isStreaming,
|
||||
isLastAssistant,
|
||||
}: MessageBubbleProps) {
|
||||
const { width } = useWindowDimensions();
|
||||
const colors = useThemeColor();
|
||||
const isLargeScreen = width > 768;
|
||||
const isUser = message.role === 'user';
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [copiedText, setCopiedText] = useState<string | null>(null);
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
await Clipboard.setStringAsync(text);
|
||||
setCopiedText(text);
|
||||
setTimeout(() => setCopiedText(null), 2000);
|
||||
};
|
||||
|
||||
const showingStreaming = isStreaming && isLastAssistant;
|
||||
const shouldShowActions = !isUser || showActions;
|
||||
|
||||
// UseMemo for dynamic colors
|
||||
const markdownStyles = useMemo(() => ({
|
||||
body: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
color: colors.messageAssistantText,
|
||||
},
|
||||
strong: {
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
em: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
code_inline: {
|
||||
backgroundColor: colors.backgroundColor,
|
||||
color: colors.text,
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
fence: {
|
||||
backgroundColor: colors.inputBg,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
color: colors.text,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
marginVertical: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: colors.inputBg,
|
||||
color: colors.text,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
marginVertical: 8,
|
||||
},
|
||||
blockquote: {
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.border,
|
||||
paddingLeft: 12,
|
||||
marginVertical: 4,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
bullet_list: {
|
||||
marginVertical: 4,
|
||||
},
|
||||
ordered_list: {
|
||||
marginVertical: 4,
|
||||
},
|
||||
list_item: {
|
||||
marginVertical: 2,
|
||||
color: colors.messageAssistantText,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
marginVertical: 8,
|
||||
color: colors.text,
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 19,
|
||||
fontWeight: '700',
|
||||
marginVertical: 6,
|
||||
color: colors.text,
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
marginVertical: 4,
|
||||
color: colors.text,
|
||||
},
|
||||
link: {
|
||||
color: '#3B82F6',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
hr: {
|
||||
backgroundColor: colors.border,
|
||||
height: 1,
|
||||
marginVertical: 12,
|
||||
},
|
||||
paragraph: {
|
||||
marginVertical: 2,
|
||||
},
|
||||
} as any), [colors]); // using 'as any' to satisfy internal Markdown types if needed
|
||||
|
||||
const markdownRules = useMemo(() => ({
|
||||
fence: (node: any, children: any, parent: any, parentStyles: any) => {
|
||||
const content = node.content || '';
|
||||
const language = node.sourceInfo || 'code';
|
||||
return (
|
||||
<View key={node.key} style={styles.codeBlockWrapper}>
|
||||
<View style={styles.codeBlockHeader}>
|
||||
<Text style={styles.codeBlockLanguage}>{language}</Text>
|
||||
<TouchableOpacity onPress={() => handleCopy(content)} style={styles.copyButton}>
|
||||
{copiedText === content ? <Check size={14} color="#A0A0A0" /> : <Copy size={14} color="#A0A0A0" />}
|
||||
<Text style={styles.copyText}>{copiedText === content ? 'Copied!' : 'Copy'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
|
||||
<Text style={styles.codeText}>{content}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
code_block: (node: any, children: any, parent: any, parentStyles: any) => {
|
||||
const content = node.content || '';
|
||||
return (
|
||||
<View key={node.key} style={styles.codeBlockWrapper}>
|
||||
<View style={styles.codeBlockHeader}>
|
||||
<Text style={styles.codeBlockLanguage}>code</Text>
|
||||
<TouchableOpacity onPress={() => handleCopy(content)} style={styles.copyButton}>
|
||||
{copiedText === content ? <Check size={14} color="#A0A0A0" /> : <Copy size={14} color="#A0A0A0" />}
|
||||
<Text style={styles.copyText}>{copiedText === content ? 'Copied!' : 'Copy'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView horizontal style={styles.codeScrollView} showsHorizontalScrollIndicator={false}>
|
||||
<Text style={styles.codeText}>{content}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}), [copiedText]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isUser) {
|
||||
return (
|
||||
<Text style={[styles.text, { color: colors.messageUserText }]}>
|
||||
{message.content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!message.content && showingStreaming) {
|
||||
return null; // TypingIndicator handled externally
|
||||
}
|
||||
|
||||
return (
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{message.content}
|
||||
</Markdown>
|
||||
);
|
||||
};
|
||||
const showingStreaming = !!isStreaming && !!isLastAssistant;
|
||||
const actionsVisible = (!isUser || showActions) && message.content.length > 0;
|
||||
|
||||
return (
|
||||
<View
|
||||
onTouchStart={() => isUser && setShowActions(true)}
|
||||
style={styles.outerWrap}
|
||||
>
|
||||
<View style={[
|
||||
styles.container,
|
||||
isUser ? styles.containerUser : styles.containerAssistant,
|
||||
]}>
|
||||
{/* Avatar */}
|
||||
{!isUser && (
|
||||
<View style={[styles.avatarAssistant, { backgroundColor: colors.border }]}>
|
||||
<Bot size={16} color={colors.text} />
|
||||
</View>
|
||||
)}
|
||||
<BubbleRow message={message} showingStreaming={showingStreaming} />
|
||||
<BubbleMeta message={message} />
|
||||
|
||||
<View style={[
|
||||
styles.bubble,
|
||||
isUser ? styles.bubbleUser : styles.bubbleAssistant,
|
||||
{ maxWidth: isLargeScreen ? '70%' : '85%' },
|
||||
{ backgroundColor: isUser ? colors.messageUserBg : colors.messageAssistantBg },
|
||||
!isUser && { borderWidth: 1, borderColor: colors.border }
|
||||
]}>
|
||||
{renderContent()}
|
||||
</View>
|
||||
|
||||
{isUser && (
|
||||
<View style={[styles.avatarUser, { backgroundColor: colors.tint }]}>
|
||||
<User size={16} color={colors.backgroundColor} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Timestamp + edited badge */}
|
||||
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
|
||||
{!isUser && <View style={{ width: 32 }} />}
|
||||
<Text style={[styles.timestamp, { color: colors.textMuted }]}>
|
||||
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{message.isEdited ? ' · edited' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Action buttons */}
|
||||
{(shouldShowActions || !isUser) && message.content.length > 0 && (
|
||||
{actionsVisible && (
|
||||
<MessageActions
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
@@ -266,15 +121,7 @@ const styles = StyleSheet.create({
|
||||
containerAssistant: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
avatarAssistant: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 2,
|
||||
},
|
||||
avatarUser: {
|
||||
avatar: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
@@ -295,10 +142,6 @@ const styles = StyleSheet.create({
|
||||
bubbleAssistant: {
|
||||
borderBottomLeftRadius: 6,
|
||||
},
|
||||
text: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
@@ -316,45 +159,4 @@ const styles = StyleSheet.create({
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
codeBlockWrapper: {
|
||||
backgroundColor: '#1E1E1E',
|
||||
borderRadius: 8,
|
||||
marginVertical: 8,
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
},
|
||||
codeBlockHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#2D2D2D',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#404040',
|
||||
},
|
||||
codeBlockLanguage: {
|
||||
color: '#A0A0A0',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
textTransform: 'lowercase',
|
||||
},
|
||||
copyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: 4,
|
||||
},
|
||||
copyText: {
|
||||
color: '#A0A0A0',
|
||||
fontSize: 12,
|
||||
},
|
||||
codeScrollView: {
|
||||
padding: 12,
|
||||
},
|
||||
codeText: {
|
||||
color: '#D4D4D4',
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { ChatMessage } from '@/store/useAppStore';
|
||||
import Markdown from '@ronradtke/react-native-markdown-display';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { StyleSheet, Text } from 'react-native';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { createMarkdownStyles } from './markdownStyles';
|
||||
|
||||
interface MessageContentProps {
|
||||
message: ChatMessage;
|
||||
/** True while this message is the answer currently being streamed. */
|
||||
showingStreaming: boolean;
|
||||
}
|
||||
|
||||
export function MessageContent({ message, showingStreaming }: MessageContentProps) {
|
||||
const colors = useThemeColor();
|
||||
const [copiedText, setCopiedText] = useState<string | null>(null);
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
await Clipboard.setStringAsync(text);
|
||||
setCopiedText(text);
|
||||
setTimeout(() => setCopiedText(null), 2000);
|
||||
};
|
||||
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(colors), [colors]);
|
||||
|
||||
const markdownRules = useMemo(() => ({
|
||||
fence: (node: any) => (
|
||||
<CodeBlock
|
||||
key={node.key}
|
||||
language={node.sourceInfo || 'code'}
|
||||
content={node.content || ''}
|
||||
copied={copiedText === (node.content || '')}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
),
|
||||
code_block: (node: any) => (
|
||||
<CodeBlock
|
||||
key={node.key}
|
||||
language="code"
|
||||
content={node.content || ''}
|
||||
copied={copiedText === (node.content || '')}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
),
|
||||
}), [copiedText]);
|
||||
|
||||
if (message.role === 'user') {
|
||||
return (
|
||||
<Text style={[styles.text, { color: colors.messageUserText }]}>
|
||||
{message.content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!message.content && showingStreaming) {
|
||||
return null; // TypingIndicator handled externally
|
||||
}
|
||||
|
||||
return (
|
||||
<Markdown style={markdownStyles} rules={markdownRules}>
|
||||
{message.content}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
text: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { MessageBubble } from '@/components/chat/MessageBubble';
|
||||
import { TypingIndicator } from '@/components/chat/TypingIndicator';
|
||||
import { ChatMessage } from '@/store/useAppStore';
|
||||
import { findLastAssistantId, shouldShowTypingIndicator } from '@/utils/chatSelectors';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { FlatList, StyleSheet } from 'react-native';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRegenerate: () => void;
|
||||
}
|
||||
|
||||
interface MessageRowProps {
|
||||
message: ChatMessage;
|
||||
delayed: boolean;
|
||||
isStreaming: boolean;
|
||||
isLastAssistant: boolean;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRegenerate: () => void;
|
||||
}
|
||||
|
||||
// Memoized so that a streamed token only re-renders (and re-parses the Markdown of)
|
||||
// the message that changed, not the whole conversation.
|
||||
const MessageRow = React.memo(function MessageRow({
|
||||
message,
|
||||
delayed,
|
||||
isStreaming,
|
||||
isLastAssistant,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRegenerate,
|
||||
}: MessageRowProps) {
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.duration(300).delay(delayed ? 100 : 0)}>
|
||||
<MessageBubble
|
||||
message={message}
|
||||
onEdit={() => onEdit(message.id)}
|
||||
onRegenerate={onRegenerate}
|
||||
onDelete={() => onDelete(message.id)}
|
||||
isStreaming={isStreaming}
|
||||
isLastAssistant={isLastAssistant}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
});
|
||||
|
||||
export function MessageList({ messages, isStreaming, onEdit, onDelete, onRegenerate }: MessageListProps) {
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const lastAssistantId = findLastAssistantId(messages);
|
||||
const lastContentLength = messages[messages.length - 1]?.content?.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0 && flatListRef.current) {
|
||||
setTimeout(() => {
|
||||
flatListRef.current?.scrollToEnd({ animated: !isStreaming });
|
||||
}, 100);
|
||||
}
|
||||
}, [messages.length, lastContentLength, isStreaming]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item, index }) => (
|
||||
<MessageRow
|
||||
message={item}
|
||||
delayed={index > messages.length - 4}
|
||||
isStreaming={isStreaming}
|
||||
isLastAssistant={item.id === lastAssistantId}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onRegenerate={onRegenerate}
|
||||
/>
|
||||
)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
{shouldShowTypingIndicator(messages, isStreaming) && <TypingIndicator />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingTop: 16,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
});
|
||||
@@ -42,6 +42,8 @@ export function ModelSelector() {
|
||||
style={[styles.headerButton, { backgroundColor: colors.cardBackground }]}
|
||||
onPress={() => setModalVisible(true)}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Model: ${activeModelName}. Tap to change`}
|
||||
>
|
||||
<ConnectionBadge isConnected={isConnected} compact />
|
||||
<Text style={[styles.headerText, { color: colors.text }]} numberOfLines={1}>
|
||||
@@ -70,6 +72,8 @@ export function ModelSelector() {
|
||||
onPress={handleRefresh}
|
||||
style={[styles.refreshButton, { backgroundColor: colors.cardBackground }]}
|
||||
disabled={isRefreshing}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Refresh models"
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<ActivityIndicator size="small" color={colors.textMuted} />
|
||||
@@ -108,6 +112,9 @@ export function ModelSelector() {
|
||||
isSelected && { borderColor: colors.tint }
|
||||
]}
|
||||
onPress={() => handleSelect(item.id)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={item.id}
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
>
|
||||
<View style={styles.itemContent}>
|
||||
<Text style={[styles.itemName, { color: colors.text }]}>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
export function OfflineBanner() {
|
||||
const colors = useThemeColor();
|
||||
|
||||
return (
|
||||
<View
|
||||
accessibilityRole="alert"
|
||||
style={[styles.banner, { backgroundColor: colors.bannerBg, borderBottomColor: colors.bannerBg }]}
|
||||
>
|
||||
<Text style={[styles.bannerText, { color: colors.bannerText }]}>
|
||||
⚠️ Server offline. Check Settings → Server Connection.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
banner: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
bannerText: {
|
||||
fontSize: 13,
|
||||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
|
||||
type ThemeColors = ReturnType<typeof useThemeColor>;
|
||||
|
||||
/** Style map for the Markdown renderer, derived from the active theme colours. */
|
||||
export function createMarkdownStyles(colors: ThemeColors) {
|
||||
return {
|
||||
body: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
color: colors.messageAssistantText,
|
||||
},
|
||||
strong: {
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
em: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
code_inline: {
|
||||
backgroundColor: colors.backgroundColor,
|
||||
color: colors.text,
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
fence: {
|
||||
backgroundColor: colors.inputBg,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 1,
|
||||
color: colors.text,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
marginVertical: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: colors.inputBg,
|
||||
color: colors.text,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
marginVertical: 8,
|
||||
},
|
||||
blockquote: {
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.border,
|
||||
paddingLeft: 12,
|
||||
marginVertical: 4,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
bullet_list: {
|
||||
marginVertical: 4,
|
||||
},
|
||||
ordered_list: {
|
||||
marginVertical: 4,
|
||||
},
|
||||
list_item: {
|
||||
marginVertical: 2,
|
||||
color: colors.messageAssistantText,
|
||||
},
|
||||
heading1: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
marginVertical: 8,
|
||||
color: colors.text,
|
||||
},
|
||||
heading2: {
|
||||
fontSize: 19,
|
||||
fontWeight: '700',
|
||||
marginVertical: 6,
|
||||
color: colors.text,
|
||||
},
|
||||
heading3: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
marginVertical: 4,
|
||||
color: colors.text,
|
||||
},
|
||||
link: {
|
||||
color: '#3B82F6',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
hr: {
|
||||
backgroundColor: colors.border,
|
||||
height: 1,
|
||||
marginVertical: 12,
|
||||
},
|
||||
paragraph: {
|
||||
marginVertical: 2,
|
||||
},
|
||||
// The Markdown renderer's style types do not accept every RN style key literal.
|
||||
} as any;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Href, Link } from 'expo-router';
|
||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||
import { type ComponentProps } from 'react';
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||
|
||||
export function ExternalLink({ href, ...rest }: Props) {
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
{...rest}
|
||||
href={href}
|
||||
onPress={async (event) => {
|
||||
if (process.env.EXPO_OS !== 'web') {
|
||||
// Prevent the default behavior of linking to the default browser on native.
|
||||
event.preventDefault();
|
||||
// Open the link in an in-app browser.
|
||||
await openBrowserAsync(href, {
|
||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||
import { PlatformPressable } from '@react-navigation/elements';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||
return (
|
||||
<PlatformPressable
|
||||
{...props}
|
||||
onPressIn={(ev) => {
|
||||
if (process.env.EXPO_OS === 'ios') {
|
||||
// Add a soft haptic feedback when pressing down on the tabs.
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
props.onPressIn?.(ev);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
export function HelloWave() {
|
||||
return (
|
||||
<Animated.Text
|
||||
style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 32,
|
||||
marginTop: -6,
|
||||
animationName: {
|
||||
'50%': { transform: [{ rotate: '25deg' }] },
|
||||
},
|
||||
animationIterationCount: 4,
|
||||
animationDuration: '300ms',
|
||||
}}>
|
||||
👋
|
||||
</Animated.Text>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useScrollOffset,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
const HEADER_HEIGHT = 250;
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
headerImage: ReactElement;
|
||||
headerBackgroundColor: { dark: string; light: string };
|
||||
}>;
|
||||
|
||||
export default function ParallaxScrollView({
|
||||
children,
|
||||
headerImage,
|
||||
headerBackgroundColor,
|
||||
}: Props) {
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const colorScheme = useColorScheme() ?? 'light';
|
||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||
const scrollOffset = useScrollOffset(scrollRef);
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={scrollRef}
|
||||
style={{ backgroundColor, flex: 1 }}
|
||||
scrollEventThrottle={16}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.header,
|
||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||
headerAnimatedStyle,
|
||||
]}>
|
||||
{headerImage}
|
||||
</Animated.View>
|
||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||
</Animated.ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
height: HEADER_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
gap: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { Info, MonitorCog } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
|
||||
export function AboutCard() {
|
||||
const colors = useThemeColor();
|
||||
|
||||
return (
|
||||
<SettingsSection title="ABOUT">
|
||||
<View style={styles.rowGroup}>
|
||||
<Info size={18} color={colors.textMuted} style={styles.infoIcon} />
|
||||
<Text style={[styles.infoText, { color: colors.text }]}>LocalMind - Cross Platform Version</Text>
|
||||
</View>
|
||||
<View style={[styles.rowGroup, { marginTop: 8 }]}>
|
||||
<MonitorCog size={18} color={colors.textMuted} style={styles.infoIcon} />
|
||||
<Text style={[styles.infoText, { color: colors.textMuted, fontSize: 13 }]}>
|
||||
Connected via OpenAPI `/v1/chat/completions` specs
|
||||
</Text>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
rowGroup: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
infoIcon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { isDarkTheme } from '@/utils/theme';
|
||||
import { Moon } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Switch, Text, View } from 'react-native';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
|
||||
export function AppearanceCard() {
|
||||
const colors = useThemeColor();
|
||||
const systemScheme = useColorScheme();
|
||||
const themePreference = useAppStore(state => state.theme);
|
||||
const setTheme = useAppStore(state => state.setTheme);
|
||||
|
||||
const isDarkMode = isDarkTheme(themePreference, systemScheme);
|
||||
const toggleTheme = () => setTheme(isDarkMode ? 'light' : 'dark');
|
||||
|
||||
return (
|
||||
<SettingsSection title="APPEARANCE">
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowIconWrap}>
|
||||
<Moon size={20} color={colors.text} />
|
||||
</View>
|
||||
<View style={styles.rowText}>
|
||||
<Text style={[styles.rowTitle, { color: colors.text }]}>Dark Mode</Text>
|
||||
<Text style={[styles.rowSubtitle, { color: colors.textMuted }]}>Toggle sleek dark theme</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={isDarkMode}
|
||||
onValueChange={toggleTheme}
|
||||
accessibilityLabel="Dark Mode"
|
||||
trackColor={{ false: '#E2E8F0', true: colors.tint }}
|
||||
thumbColor={'#FFFFFF'}
|
||||
/>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 4,
|
||||
},
|
||||
rowIconWrap: {
|
||||
marginRight: 12,
|
||||
},
|
||||
rowText: {
|
||||
flex: 1,
|
||||
},
|
||||
rowTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
rowSubtitle: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, TextInput, TextInputProps, View } from 'react-native';
|
||||
|
||||
interface LabeledInputProps extends TextInputProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function LabeledInput({ label, style, ...inputProps }: LabeledInputProps) {
|
||||
const colors = useThemeColor();
|
||||
|
||||
return (
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: colors.textMuted }]}>{label}</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
{ backgroundColor: colors.inputBg, borderColor: colors.inputBorder, color: colors.text },
|
||||
style,
|
||||
]}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
{...inputProps}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
inputGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
},
|
||||
textInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
|
||||
import { useLMStudio } from '@/hooks/useLMStudio';
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import * as api from '@/services/lmStudioApi';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { buildServerConfig, isConfigChanged } from '@/utils/serverConfig';
|
||||
import { Network } from 'lucide-react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { LabeledInput } from './LabeledInput';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
|
||||
function TestResultBox({ ok }: { ok: boolean }) {
|
||||
const colors = useThemeColor();
|
||||
|
||||
return (
|
||||
<View style={[styles.resultBox, { backgroundColor: ok ? colors.successBg : colors.errorBg }]}>
|
||||
<Text style={[styles.resultText, { color: ok ? colors.successText : colors.errorText }]}>
|
||||
{ok
|
||||
? 'Connection successful. API is ready.'
|
||||
: 'Connection failed. Verify host and port.'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerConnectionCard() {
|
||||
const colors = useThemeColor();
|
||||
const serverConfig = useAppStore(state => state.serverConfig);
|
||||
const setServerConfig = useAppStore(state => state.setServerConfig);
|
||||
const isConnected = useAppStore(state => state.isConnected);
|
||||
const setIsConnected = useAppStore(state => state.setIsConnected);
|
||||
const { fetchModels } = useLMStudio();
|
||||
|
||||
const [host, setHost] = useState(serverConfig.host);
|
||||
const [port, setPort] = useState(serverConfig.port.toString());
|
||||
const [apiKey, setApiKey] = useState(serverConfig.apiKey ?? '');
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<boolean | null>(null);
|
||||
|
||||
const form = { host, port, apiKey };
|
||||
const currentConfig = buildServerConfig(form);
|
||||
const configChanged = isConfigChanged(form, serverConfig);
|
||||
|
||||
const handleSaveConfig = () => {
|
||||
setServerConfig(currentConfig);
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
handleSaveConfig();
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
const ok = await api.testConnection(currentConfig);
|
||||
setTestResult(ok);
|
||||
setIsConnected(ok);
|
||||
if (ok) {
|
||||
await fetchModels();
|
||||
}
|
||||
setIsTesting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="SERVER CONNECTION">
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardHeaderTitle}>
|
||||
<Network size={20} color={colors.text} style={styles.headerIcon} />
|
||||
<Text style={[styles.cardTitle, { color: colors.text }]}>LM Studio API</Text>
|
||||
</View>
|
||||
<ConnectionBadge isConnected={isConnected} />
|
||||
</View>
|
||||
|
||||
<View style={[styles.divider, { backgroundColor: colors.border }]} />
|
||||
|
||||
<LabeledInput
|
||||
label="Host IP / Address"
|
||||
value={host}
|
||||
onChangeText={setHost}
|
||||
placeholder="localhost"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<LabeledInput
|
||||
label="Port Number"
|
||||
value={port}
|
||||
onChangeText={setPort}
|
||||
placeholder="1234"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
<LabeledInput
|
||||
label="API Key (optional)"
|
||||
value={apiKey}
|
||||
onChangeText={setApiKey}
|
||||
placeholder="Leave empty for local"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<View style={styles.buttonRow}>
|
||||
{configChanged && (
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, { borderColor: colors.border, backgroundColor: colors.cardBackground }]}
|
||||
onPress={handleSaveConfig}
|
||||
>
|
||||
<Text style={[styles.saveButtonText, { color: colors.text }]}>Save Changes</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.testButton,
|
||||
{ backgroundColor: colors.tint },
|
||||
isTesting && styles.testButtonDisabled
|
||||
]}
|
||||
onPress={handleTestConnection}
|
||||
disabled={isTesting}
|
||||
>
|
||||
{isTesting ? (
|
||||
<ActivityIndicator size="small" color={colors.backgroundColor} />
|
||||
) : (
|
||||
<Text style={[styles.testButtonText, { color: colors.backgroundColor }]}>Test Connection</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{testResult !== null && <TestResultBox ok={testResult} />}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 16,
|
||||
},
|
||||
cardHeaderTitle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
marginBottom: 16,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
saveButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
},
|
||||
saveButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
testButton: {
|
||||
flex: 2,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
testButtonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
testButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
resultBox: {
|
||||
marginTop: 16,
|
||||
borderRadius: 12,
|
||||
padding: 14,
|
||||
},
|
||||
resultText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native';
|
||||
|
||||
interface SettingsSectionProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsSection({ title, children }: SettingsSectionProps) {
|
||||
const { width } = useWindowDimensions();
|
||||
const colors = useThemeColor();
|
||||
const isLargeScreen = width > 768;
|
||||
|
||||
return (
|
||||
<View style={[styles.section, isLargeScreen && styles.desktopSection]}>
|
||||
<Text style={[styles.sectionLabel, { color: colors.textMuted }]}>{title}</Text>
|
||||
<View style={[styles.card, { backgroundColor: colors.cardBackground, borderColor: colors.border }]}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
desktopSection: {
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginLeft: 16,
|
||||
marginBottom: 8,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
},
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
type = 'default',
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color },
|
||||
type === 'default' ? styles.default : undefined,
|
||||
type === 'title' ? styles.title : undefined,
|
||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||
type === 'subtitle' ? styles.subtitle : undefined,
|
||||
type === 'link' ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: '#0a7ea4',
|
||||
},
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { View, type ViewProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Text, TextStyle, ViewStyle } from 'react-native';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
style?: ViewStyle;
|
||||
textStyle?: TextStyle;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
style,
|
||||
textStyle,
|
||||
icon
|
||||
}: ButtonProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const theme = Colors[colorScheme ?? 'dark'];
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
if (disabled) return theme.surfaceHighlight;
|
||||
switch (variant) {
|
||||
case 'primary': return theme.primary;
|
||||
case 'secondary': return theme.surfaceHighlight;
|
||||
case 'outline': return 'transparent';
|
||||
case 'ghost': return 'transparent';
|
||||
default: return theme.primary;
|
||||
}
|
||||
};
|
||||
|
||||
const getTextColor = () => {
|
||||
if (disabled) return theme.textSecondary;
|
||||
switch (variant) {
|
||||
case 'primary': return theme.background; // Black text on white bg
|
||||
case 'secondary': return theme.text;
|
||||
case 'outline': return theme.text;
|
||||
case 'ghost': return theme.textSecondary;
|
||||
default: return theme.background;
|
||||
}
|
||||
};
|
||||
|
||||
const getBorder = () => {
|
||||
if (variant === 'outline') return { borderWidth: 1, borderColor: theme.border };
|
||||
return {};
|
||||
};
|
||||
|
||||
const getPadding = () => {
|
||||
switch (size) {
|
||||
case 'sm': return { paddingVertical: 8, paddingHorizontal: 12 };
|
||||
case 'lg': return { paddingVertical: 16, paddingHorizontal: 24 };
|
||||
default: return { paddingVertical: 12, paddingHorizontal: 20 };
|
||||
}
|
||||
};
|
||||
|
||||
const getFontSize = () => {
|
||||
switch (size) {
|
||||
case 'sm': return 13;
|
||||
case 'lg': return 17;
|
||||
default: return 15;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled || loading}
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
{ backgroundColor: getBackgroundColor() },
|
||||
getBorder(),
|
||||
getPadding(),
|
||||
{ opacity: pressed ? 0.8 : 1 },
|
||||
style
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={getTextColor()} />
|
||||
) : (
|
||||
<>
|
||||
{icon}
|
||||
<Text style={[
|
||||
styles.text,
|
||||
{ color: getTextColor(), fontSize: getFontSize() },
|
||||
icon ? { marginLeft: 8 } : {},
|
||||
textStyle
|
||||
]}>
|
||||
{title}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
borderRadius: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
text: {
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { StyleSheet, View, ViewStyle } from 'react-native';
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
variant?: 'default' | 'highlight';
|
||||
}
|
||||
|
||||
export function GlassCard({ children, style, variant = 'default' }: GlassCardProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const theme = Colors[colorScheme ?? 'dark'];
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: variant === 'default' ? theme.surface : theme.surfaceHighlight,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
style
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 16,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { ScrollView, StyleSheet, View, ViewStyle } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
interface ScreenWrapperProps {
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
contentContainerStyle?: ViewStyle;
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
export function ScreenWrapper({
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
scrollable = false
|
||||
}: ScreenWrapperProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const theme = Colors[colorScheme ?? 'dark'];
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const containerStyle = [
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: theme.background,
|
||||
paddingTop: insets.top,
|
||||
paddingLeft: insets.left,
|
||||
paddingRight: insets.right,
|
||||
},
|
||||
style
|
||||
];
|
||||
|
||||
if (scrollable) {
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: theme.background }}>
|
||||
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||
<ScrollView
|
||||
style={containerStyle}
|
||||
contentContainerStyle={[
|
||||
{ paddingBottom: insets.bottom + 20 },
|
||||
contentContainerStyle
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||
<View style={[{ flex: 1, paddingBottom: insets.bottom }, contentContainerStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
|
||||
return (
|
||||
<ThemedView>
|
||||
<TouchableOpacity
|
||||
style={styles.heading}
|
||||
onPress={() => setIsOpen((value) => !value)}
|
||||
activeOpacity={0.8}>
|
||||
<IconSymbol
|
||||
name="chevron.right"
|
||||
size={18}
|
||||
weight="medium"
|
||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||
/>
|
||||
|
||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
heading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
content: {
|
||||
marginTop: 6,
|
||||
marginLeft: 24,
|
||||
},
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
weight = 'regular',
|
||||
}: {
|
||||
name: SymbolViewProps['name'];
|
||||
size?: number;
|
||||
color: string;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return (
|
||||
<SymbolView
|
||||
weight={weight}
|
||||
tintColor={color}
|
||||
resizeMode="scaleAspectFit"
|
||||
name={name}
|
||||
style={[
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// Fallback for using MaterialIcons on Android and web.
|
||||
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
||||
import { ComponentProps } from 'react';
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||
|
||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||
type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
* Add your SF Symbols to Material Icons mappings here.
|
||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||
*/
|
||||
const MAPPING = {
|
||||
'house.fill': 'home',
|
||||
'paperplane.fill': 'send',
|
||||
'chevron.left.forwardslash.chevron.right': 'code',
|
||||
'chevron.right': 'chevron-right',
|
||||
} as IconMapping;
|
||||
|
||||
/**
|
||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||
*/
|
||||
export function IconSymbol({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
}: {
|
||||
name: IconSymbolName;
|
||||
size?: number;
|
||||
color: string | OpaqueColorValue;
|
||||
style?: StyleProp<TextStyle>;
|
||||
weight?: SymbolWeight;
|
||||
}) {
|
||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
export type AIModel = {
|
||||
id: string;
|
||||
name: string;
|
||||
parameters: string;
|
||||
huggingFaceUrl: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const QWEN_MODELS: AIModel[] = [
|
||||
{
|
||||
id: "qwen1.5-0.5b-chat",
|
||||
name: "Qwen 1.5 Chat",
|
||||
parameters: "0.5B",
|
||||
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-0.5B-Chat",
|
||||
description: "Ultra-lightweight model, extremely fast for basic tasks.",
|
||||
},
|
||||
{
|
||||
id: "qwen1.5-1.8b-chat",
|
||||
name: "Qwen 1.5 Chat",
|
||||
parameters: "1.8B",
|
||||
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-1.8B-Chat",
|
||||
description: "Good balance of speed and reasoning for mobile devices.",
|
||||
},
|
||||
{
|
||||
id: "qwen1.5-4b-chat",
|
||||
name: "Qwen 1.5 Chat",
|
||||
parameters: "4B",
|
||||
huggingFaceUrl: "https://huggingface.co/Qwen/Qwen1.5-4B-Chat",
|
||||
description: "Highest local quality, requires more RAM and processing power.",
|
||||
}
|
||||
];
|
||||
|
||||
export const DEFAULT_MODEL = QWEN_MODELS[0];
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* ManusAI Theme - Monochromatic/Minimalist
|
||||
*
|
||||
* Design System:
|
||||
* - Primary: White (#ffffff)
|
||||
* - Background: Deep Matte Black (#09090b)
|
||||
* - Surface: Zinc-900 (#18181b)
|
||||
* - Border: Zinc-700 (#3f3f46)
|
||||
* - Text: Zinc-50 (#fafafa)
|
||||
*/
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const ManusColors = {
|
||||
// Base
|
||||
black: '#09090b',
|
||||
zinc900: '#18181b',
|
||||
zinc800: '#27272a',
|
||||
zinc700: '#3f3f46',
|
||||
zinc400: '#a1a1aa',
|
||||
zinc50: '#fafafa',
|
||||
white: '#ffffff',
|
||||
|
||||
// Semantic
|
||||
background: '#09090b',
|
||||
surface: '#18181b',
|
||||
surfaceHighlight: '#27272a',
|
||||
primary: '#ffffff',
|
||||
border: '#3f3f46',
|
||||
text: '#fafafa',
|
||||
textSecondary: '#a1a1aa',
|
||||
error: '#ef4444',
|
||||
};
|
||||
|
||||
export const Colors = {
|
||||
light: {
|
||||
text: ManusColors.black,
|
||||
background: ManusColors.white,
|
||||
tint: ManusColors.black,
|
||||
icon: ManusColors.zinc700,
|
||||
tabIconDefault: ManusColors.zinc400,
|
||||
tabIconSelected: ManusColors.black,
|
||||
// Manus Specific
|
||||
surface: '#f4f4f5', // Zinc-100
|
||||
surfaceHighlight: '#e4e4e7', // Zinc-200
|
||||
border: '#d4d4d8', // Zinc-300
|
||||
textSecondary: ManusColors.zinc700,
|
||||
primary: ManusColors.black,
|
||||
},
|
||||
dark: {
|
||||
text: ManusColors.text,
|
||||
background: ManusColors.background,
|
||||
tint: ManusColors.primary,
|
||||
icon: ManusColors.zinc400,
|
||||
tabIconDefault: ManusColors.zinc400,
|
||||
tabIconSelected: ManusColors.primary,
|
||||
// Manus Specific
|
||||
surface: ManusColors.surface,
|
||||
surfaceHighlight: ManusColors.surfaceHighlight,
|
||||
border: ManusColors.border,
|
||||
textSecondary: ManusColors.textSecondary,
|
||||
primary: ManusColors.primary,
|
||||
},
|
||||
// Unified access for components that ignore system theme
|
||||
manus: ManusColors,
|
||||
};
|
||||
|
||||
export const Fonts = Platform.select({
|
||||
ios: {
|
||||
sans: 'system-ui',
|
||||
serif: 'ui-serif',
|
||||
rounded: 'ui-rounded',
|
||||
mono: 'ui-monospace',
|
||||
},
|
||||
android: {
|
||||
sans: 'sans-serif',
|
||||
serif: 'serif',
|
||||
rounded: 'sans-serif-medium',
|
||||
mono: 'monospace',
|
||||
},
|
||||
default: {
|
||||
sans: 'system-ui',
|
||||
serif: 'serif',
|
||||
rounded: 'system-ui',
|
||||
mono: 'monospace',
|
||||
},
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { useColorScheme } from 'react-native';
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||
|
||||
/**
|
||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||
*/
|
||||
export function useColorScheme() {
|
||||
const [hasHydrated, setHasHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
const colorScheme = useRNColorScheme();
|
||||
|
||||
if (hasHydrated) {
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Learn more about light and dark modes:
|
||||
* https://docs.expo.dev/guides/color-schemes/
|
||||
*/
|
||||
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function useThemeColor(
|
||||
props: { light?: string; dark?: string },
|
||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||
) {
|
||||
const theme = useColorScheme() ?? 'light';
|
||||
const colorFromProps = props[theme];
|
||||
|
||||
if (colorFromProps) {
|
||||
return colorFromProps;
|
||||
} else {
|
||||
return Colors[theme][colorName];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { useLMStudio } from './useLMStudio';
|
||||
|
||||
/**
|
||||
* Checks the server once when the app opens, after the saved settings have been
|
||||
* restored, so the connection badge and model list are not stale until the user
|
||||
* visits Settings.
|
||||
*/
|
||||
export function useAutoConnect() {
|
||||
const { fetchModels } = useLMStudio();
|
||||
|
||||
useEffect(() => {
|
||||
const { persist } = useAppStore;
|
||||
if (persist.hasHydrated()) {
|
||||
void fetchModels();
|
||||
return;
|
||||
}
|
||||
return persist.onFinishHydration(() => void fetchModels());
|
||||
// Mount only: later checks are triggered by the user (model selector, Settings).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
||||
+55
-97
@@ -2,7 +2,16 @@ import { useCallback, useRef } from 'react';
|
||||
import { SYSTEM_PROMPT } from '../constants/SystemPrompt';
|
||||
import * as api from '../services/lmStudioApi';
|
||||
import { ChatMessage as ApiMessage } from '../services/types';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { ChatMessage, useAppStore } from '../store/useAppStore';
|
||||
|
||||
const NO_MODEL_MESSAGE = '⚠️ No model selected. Please select a model first.';
|
||||
|
||||
function buildApiMessages(messages: ChatMessage[]): ApiMessage[] {
|
||||
return [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
...messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
];
|
||||
}
|
||||
|
||||
export function useLMStudio() {
|
||||
const serverConfig = useAppStore((s) => s.serverConfig);
|
||||
@@ -44,95 +53,16 @@ export function useLMStudio() {
|
||||
}
|
||||
}, [activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
|
||||
|
||||
const sendMessage = useCallback(async (text: string) => {
|
||||
if (isStreaming) return;
|
||||
|
||||
addMessage({ role: 'user', content: text });
|
||||
|
||||
const currentMessages = useAppStore.getState().messages;
|
||||
const apiMessages: ApiMessage[] = [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
...currentMessages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}))
|
||||
];
|
||||
|
||||
const modelId = useAppStore.getState().activeModelId;
|
||||
if (!modelId) {
|
||||
addMessage({
|
||||
role: 'assistant',
|
||||
content: '⚠️ No model selected. Please select a model first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Adds an empty assistant message and streams the model's answer into it.
|
||||
const streamAnswer = useCallback((modelId: string, apiMessages: ApiMessage[]) => {
|
||||
const assistantId = addMessage({ role: 'assistant', content: '' });
|
||||
setIsStreaming(true);
|
||||
|
||||
try {
|
||||
abortRef.current = api.chatCompletionStream(
|
||||
serverConfig,
|
||||
modelId,
|
||||
apiMessages,
|
||||
(chunk) => {
|
||||
appendToMessage(assistantId, chunk);
|
||||
},
|
||||
() => {
|
||||
setIsStreaming(false);
|
||||
abortRef.current = null;
|
||||
useAppStore.getState().saveCurrentChat();
|
||||
},
|
||||
(error) => {
|
||||
const errorMsg = `\n\n⚠️ Error: ${error.message}`;
|
||||
appendToMessage(assistantId, errorMsg);
|
||||
setIsStreaming(false);
|
||||
abortRef.current = null;
|
||||
useAppStore.getState().saveCurrentChat();
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
const errorMsg = `⚠️ Connection failed: ${(err as Error).message}`;
|
||||
appendToMessage(assistantId, errorMsg);
|
||||
const finish = () => {
|
||||
setIsStreaming(false);
|
||||
abortRef.current = null;
|
||||
useAppStore.getState().saveCurrentChat();
|
||||
}
|
||||
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
|
||||
|
||||
const regenerateResponse = useCallback(async () => {
|
||||
if (isStreaming) return;
|
||||
|
||||
const msgs = useAppStore.getState().messages;
|
||||
// Find the last user message
|
||||
let lastUserIdx = -1;
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].role === 'user') {
|
||||
lastUserIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastUserIdx === -1) return;
|
||||
|
||||
const lastUserMsg = msgs[lastUserIdx];
|
||||
// Remove messages after the last user message
|
||||
const store = useAppStore.getState();
|
||||
store.deleteMessagesAfter(lastUserMsg.id);
|
||||
|
||||
// Build API messages from remaining
|
||||
const remaining = useAppStore.getState().messages;
|
||||
const apiMessages: ApiMessage[] = [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
...remaining.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}))
|
||||
];
|
||||
|
||||
const modelId = useAppStore.getState().activeModelId;
|
||||
if (!modelId) return;
|
||||
|
||||
const assistantId = addMessage({ role: 'assistant', content: '' });
|
||||
setIsStreaming(true);
|
||||
};
|
||||
|
||||
try {
|
||||
abortRef.current = api.chatCompletionStream(
|
||||
@@ -140,30 +70,58 @@ export function useLMStudio() {
|
||||
modelId,
|
||||
apiMessages,
|
||||
(chunk) => appendToMessage(assistantId, chunk),
|
||||
() => {
|
||||
setIsStreaming(false);
|
||||
abortRef.current = null;
|
||||
useAppStore.getState().saveCurrentChat();
|
||||
},
|
||||
finish,
|
||||
(error) => {
|
||||
appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
|
||||
setIsStreaming(false);
|
||||
abortRef.current = null;
|
||||
useAppStore.getState().saveCurrentChat();
|
||||
appendToMessage(assistantId, `
|
||||
|
||||
⚠️ Error: ${error.message}`);
|
||||
finish();
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
appendToMessage(assistantId, `⚠️ Connection failed: ${(err as Error).message}`);
|
||||
setIsStreaming(false);
|
||||
useAppStore.getState().saveCurrentChat();
|
||||
finish();
|
||||
}
|
||||
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
|
||||
}, [serverConfig, addMessage, appendToMessage, setIsStreaming]);
|
||||
|
||||
const sendMessage = useCallback(async (text: string) => {
|
||||
if (isStreaming) return;
|
||||
|
||||
addMessage({ role: 'user', content: text });
|
||||
|
||||
const modelId = useAppStore.getState().activeModelId;
|
||||
if (!modelId) {
|
||||
addMessage({ role: 'assistant', content: NO_MODEL_MESSAGE });
|
||||
return;
|
||||
}
|
||||
|
||||
streamAnswer(modelId, buildApiMessages(useAppStore.getState().messages));
|
||||
}, [isStreaming, addMessage, streamAnswer]);
|
||||
|
||||
const regenerateResponse = useCallback(async () => {
|
||||
if (isStreaming) return;
|
||||
|
||||
const { messages: msgs, activeModelId: modelId, deleteMessagesAfter } = useAppStore.getState();
|
||||
const lastUserMsg = [...msgs].reverse().find((m) => m.role === 'user');
|
||||
if (!lastUserMsg) return;
|
||||
|
||||
// Checked before deleting anything, so the current answer is not lost for nothing.
|
||||
if (!modelId) {
|
||||
addMessage({ role: 'assistant', content: NO_MODEL_MESSAGE });
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop the previous answer(s) and ask again from the last user message.
|
||||
deleteMessagesAfter(lastUserMsg.id);
|
||||
streamAnswer(modelId, buildApiMessages(useAppStore.getState().messages));
|
||||
}, [isStreaming, addMessage, streamAnswer]);
|
||||
|
||||
const stopStreaming = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
setIsStreaming(false);
|
||||
useAppStore.getState().saveCurrentChat();
|
||||
}
|
||||
}, [setIsStreaming]);
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
preset: 'jest-expo',
|
||||
setupFiles: ['<rootDir>/jest.setup.js'],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
},
|
||||
// jest-expo's default pattern plus the ESM-only markdown renderer.
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@ronradtke/.*)',
|
||||
],
|
||||
testMatch: ['<rootDir>/__tests__/**/*.test.{ts,tsx}'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/.agent/'],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
jest.mock('@react-native-async-storage/async-storage', () =>
|
||||
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
|
||||
);
|
||||
Generated
+3363
-813
File diff suppressed because it is too large.
Load diff
+17
-14
@@ -4,14 +4,15 @@
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
"lint": "expo lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"build": "expo export --platform web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-runtime": "^7.29.0",
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -19,29 +20,25 @@
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@ronradtke/react-native-markdown-display": "^8.1.0",
|
||||
"expo": "~54.0.31",
|
||||
"expo-clipboard": "^55.0.8",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-font": "~14.0.10",
|
||||
"expo": "~54.0.37",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-constants": "~18.0.14",
|
||||
"expo-font": "~14.0.12",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-router": "~6.0.21",
|
||||
"expo-linking": "~8.0.12",
|
||||
"expo-router": "~6.0.24",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-symbols": "~1.0.8",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"immer": "^11.1.4",
|
||||
"lucide-react-native": "^0.576.0",
|
||||
"react": "^19.1.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
@@ -49,9 +46,15 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/react": "~19.1.0",
|
||||
"babel-preset-expo": "~54.0.12",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~54.0.0",
|
||||
"react-test-renderer": "^19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* This script is used to reset the project to a blank state.
|
||||
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
|
||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const readline = require("readline");
|
||||
|
||||
const root = process.cwd();
|
||||
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
|
||||
const exampleDir = "app-example";
|
||||
const newAppDir = "app";
|
||||
const exampleDirPath = path.join(root, exampleDir);
|
||||
|
||||
const indexContent = `import { Text, View } from "react-native";
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text>Edit app/index.tsx to edit this screen.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const layoutContent = `import { Stack } from "expo-router";
|
||||
|
||||
export default function RootLayout() {
|
||||
return <Stack />;
|
||||
}
|
||||
`;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const moveDirectories = async (userInput) => {
|
||||
try {
|
||||
if (userInput === "y") {
|
||||
// Create the app-example directory
|
||||
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
||||
console.log(`📁 /${exampleDir} directory created.`);
|
||||
}
|
||||
|
||||
// Move old directories to new app-example directory or delete them
|
||||
for (const dir of oldDirs) {
|
||||
const oldDirPath = path.join(root, dir);
|
||||
if (fs.existsSync(oldDirPath)) {
|
||||
if (userInput === "y") {
|
||||
const newDirPath = path.join(root, exampleDir, dir);
|
||||
await fs.promises.rename(oldDirPath, newDirPath);
|
||||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
||||
} else {
|
||||
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
||||
console.log(`❌ /${dir} deleted.`);
|
||||
}
|
||||
} else {
|
||||
console.log(`➡️ /${dir} does not exist, skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new /app directory
|
||||
const newAppDirPath = path.join(root, newAppDir);
|
||||
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
||||
console.log("\n📁 New /app directory created.");
|
||||
|
||||
// Create index.tsx
|
||||
const indexPath = path.join(newAppDirPath, "index.tsx");
|
||||
await fs.promises.writeFile(indexPath, indexContent);
|
||||
console.log("📄 app/index.tsx created.");
|
||||
|
||||
// Create _layout.tsx
|
||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
||||
await fs.promises.writeFile(layoutPath, layoutContent);
|
||||
console.log("📄 app/_layout.tsx created.");
|
||||
|
||||
console.log("\n✅ Project reset complete. Next steps:");
|
||||
console.log(
|
||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
||||
userInput === "y"
|
||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||
: ""
|
||||
}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error during script execution: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
rl.question(
|
||||
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
|
||||
(answer) => {
|
||||
const userInput = answer.trim().toLowerCase() || "y";
|
||||
if (userInput === "y" || userInput === "n") {
|
||||
moveDirectories(userInput).finally(() => rl.close());
|
||||
} else {
|
||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
);
|
||||
+39
-21
@@ -9,12 +9,18 @@ import {
|
||||
StreamChunk,
|
||||
} from './types';
|
||||
|
||||
function buildBaseUrl(config: ServerConfig): string {
|
||||
export function buildBaseUrl(config: ServerConfig): string {
|
||||
const host = config.host.replace(/\/+$/, '');
|
||||
const protocol = host.startsWith('http') ? '' : 'http://';
|
||||
const protocol = /^https?:\/\//i.test(host) ? '' : 'http://';
|
||||
return `${protocol}${host}:${config.port}/v1`;
|
||||
}
|
||||
|
||||
function connectionError(baseUrl: string): Error {
|
||||
return new Error(
|
||||
`Could not reach the server at ${baseUrl}. Check the host and port in Settings and that LM Studio's local server is running.`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildHeaders(config: ServerConfig): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -26,19 +32,19 @@ function buildHeaders(config: ServerConfig): Record<string, string> {
|
||||
}
|
||||
|
||||
export async function testConnection(config: ServerConfig): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${buildBaseUrl(config)}/models`, {
|
||||
headers: buildHeaders(config),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +90,7 @@ export async function chatCompletion(
|
||||
}
|
||||
|
||||
// Parse SSE lines from a text buffer
|
||||
function parseSSELines(
|
||||
export function parseSSELines(
|
||||
buffer: string,
|
||||
onChunk: (content: string) => void,
|
||||
onDone: () => void,
|
||||
@@ -95,9 +101,9 @@ function parseSSELines(
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
||||
if (!trimmed.startsWith('data:')) continue;
|
||||
|
||||
const payload = trimmed.slice(6);
|
||||
const payload = trimmed.slice(5).trim();
|
||||
if (payload === '[DONE]') {
|
||||
isDone = true;
|
||||
onDone();
|
||||
@@ -123,6 +129,7 @@ function streamWithXHR(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: string,
|
||||
baseUrl: string,
|
||||
onChunk: (content: string) => void,
|
||||
onDone: () => void,
|
||||
onError: (error: Error) => void,
|
||||
@@ -146,6 +153,10 @@ function streamWithXHR(
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
onError(new Error(`Stream failed: ${xhr.status} ${xhr.statusText}`));
|
||||
return;
|
||||
}
|
||||
// Process any remaining buffer
|
||||
if (buffer.trim()) {
|
||||
parseSSELines(buffer + '\n', onChunk, onDone);
|
||||
@@ -154,7 +165,7 @@ function streamWithXHR(
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
onError(new Error(`XHR streaming failed: ${xhr.status} ${xhr.statusText}`));
|
||||
onError(connectionError(baseUrl));
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
@@ -175,17 +186,23 @@ async function streamWithFetch(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: string,
|
||||
baseUrl: string,
|
||||
onChunk: (content: string) => void,
|
||||
onDone: () => void,
|
||||
onError: (error: Error) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal,
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') throw err;
|
||||
throw connectionError(baseUrl);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Stream failed: ${res.status} ${res.statusText}`);
|
||||
@@ -229,7 +246,8 @@ export function chatCompletionStream(
|
||||
...options,
|
||||
};
|
||||
|
||||
const url = `${buildBaseUrl(config)}/chat/completions`;
|
||||
const baseUrl = buildBaseUrl(config);
|
||||
const url = `${baseUrl}/chat/completions`;
|
||||
const headers = buildHeaders(config);
|
||||
const bodyStr = JSON.stringify(body);
|
||||
|
||||
@@ -245,7 +263,7 @@ export function chatCompletionStream(
|
||||
// Web: use fetch + ReadableStream
|
||||
const controller = new AbortController();
|
||||
|
||||
streamWithFetch(url, headers, bodyStr, onChunk, safeOnDone, onError, controller.signal)
|
||||
streamWithFetch(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, controller.signal)
|
||||
.catch((err) => {
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
onError(err as Error);
|
||||
@@ -255,6 +273,6 @@ export function chatCompletionStream(
|
||||
return { abort: () => controller.abort() };
|
||||
} else {
|
||||
// Native: use XMLHttpRequest
|
||||
return streamWithXHR(url, headers, bodyStr, onChunk, safeOnDone, onError);
|
||||
return streamWithXHR(url, headers, bodyStr, baseUrl, onChunk, safeOnDone, onError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { AppState } from 'react-native';
|
||||
import type { StateStorage } from 'zustand/middleware';
|
||||
|
||||
export interface DebouncedStorage extends StateStorage {
|
||||
/** Writes every pending value right away. */
|
||||
flush: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a storage so bursts of writes collapse into one trailing write.
|
||||
*
|
||||
* The chat store is persisted on every state change and a streamed answer
|
||||
* changes it once per token, which would serialize every saved session and hit
|
||||
* AsyncStorage dozens of times per second.
|
||||
*/
|
||||
export function createDebouncedStorage(base: StateStorage, delayMs = 400): DebouncedStorage {
|
||||
const pending = new Map<string, string>();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flush = async () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
const entries = [...pending.entries()];
|
||||
pending.clear();
|
||||
await Promise.all(entries.map(([name, value]) => base.setItem(name, value)));
|
||||
};
|
||||
|
||||
return {
|
||||
getItem: (name) => {
|
||||
const value = pending.get(name);
|
||||
return value === undefined ? base.getItem(name) : Promise.resolve(value);
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
pending.set(name, value);
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(flush, delayMs);
|
||||
},
|
||||
removeItem: (name) => {
|
||||
pending.delete(name);
|
||||
return base.removeItem(name);
|
||||
},
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
||||
export const appStorage = createDebouncedStorage(AsyncStorage);
|
||||
|
||||
// Do not lose the last tokens when the app is sent to the background or closed.
|
||||
AppState.addEventListener('change', (state) => {
|
||||
if (state !== 'active') void appStorage.flush();
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { create } from 'zustand';
|
||||
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
import { DEFAULT_SERVER_CONFIG, LMModel, ServerConfig } from '../services/types';
|
||||
import { appStorage } from './debouncedStorage';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
@@ -164,10 +164,11 @@ export const useAppStore = create<AppState>()(
|
||||
}),
|
||||
{
|
||||
name: 'localaiapp-storage',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
storage: createJSONStorage(() => appStorage),
|
||||
partialize: (state) => ({
|
||||
theme: state.theme,
|
||||
serverConfig: state.serverConfig,
|
||||
activeModelId: state.activeModelId,
|
||||
chatSessions: state.chatSessions,
|
||||
messages: state.messages,
|
||||
currentChatId: state.currentChatId,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ChatMessage } from '../store/useAppStore';
|
||||
|
||||
/** Id of the most recent assistant message, or null when there is none. */
|
||||
export function findLastAssistantId(messages: ChatMessage[]): string | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'assistant') return messages[i].id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True while the model has been asked to answer but has not produced any text yet. */
|
||||
export function shouldShowTypingIndicator(messages: ChatMessage[], isStreaming: boolean): boolean {
|
||||
if (!isStreaming) return false;
|
||||
const last = messages[messages.length - 1];
|
||||
return last?.role === 'assistant' && last.content === '';
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DEFAULT_SERVER_CONFIG, ServerConfig } from '../services/types';
|
||||
|
||||
export interface ServerConfigForm {
|
||||
host: string;
|
||||
port: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/** Turns the raw text fields of the settings form into a usable ServerConfig. */
|
||||
export function buildServerConfig(form: ServerConfigForm): ServerConfig {
|
||||
return {
|
||||
host: form.host.trim() || DEFAULT_SERVER_CONFIG.host,
|
||||
port: parseInt(form.port, 10) || DEFAULT_SERVER_CONFIG.port,
|
||||
apiKey: form.apiKey.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** True when the form differs from what is currently stored. */
|
||||
export function isConfigChanged(form: ServerConfigForm, saved: ServerConfig): boolean {
|
||||
return (
|
||||
form.host !== saved.host ||
|
||||
form.port !== saved.port.toString() ||
|
||||
form.apiKey !== (saved.apiKey ?? '')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Whether the dark theme is in effect for a stored preference and the OS scheme. */
|
||||
export function isDarkTheme(
|
||||
preference: 'light' | 'dark' | 'system',
|
||||
systemScheme: 'light' | 'dark',
|
||||
): boolean {
|
||||
return preference === 'dark' || (preference === 'system' && systemScheme === 'dark');
|
||||
}
|
||||
Reference in new issue
Block a user