Skip to content

Commit 1f6e8a1

Browse files
authored
Feat: customizable system prompts (personal preferences) (#240)
## Description ### PR Objective This Pull Request adds customizable system prompts to the chat: users write global personal preferences (e.g., "be concise", "answer in Polish", "avoid jargon") that silently shape every conversation — the model follows them without ever quoting, acknowledging, or parroting them back. Resolves **#230**. --- ### What changed? #### Custom System Prompt * **Persisted setting** (`store/settingsStore.ts`): A global `customSystemPrompt` stored via Zustand + persist on AsyncStorage. * **Guard & injection** (`utils/promptUtils.ts`, `constants/prompts.ts`): `CUSTOM_PROMPT_GUARD` is prepended to the user's preference and appended to the system prompt during message assembly (`store/llmStore.ts`). Empty/whitespace-only preferences are skipped entirely — neither prompt nor guard is added. * **Soft token budget** (`constants/settings.ts`): Character cap derived from a ~256-token × ~4 chars/token approximation, so the preference can't eat the on-device context — validated on the trimmed value. #### Settings, Reworked * **Settings is now a drawer screen** (`app/(drawer)/settings.tsx`, `app/(drawer)/_layout.tsx`) — a proper destination like Models, navigated via `replace`, instead of a full-screen modal. * **Sub-pages as card-push screens** with `ios_from_right` (`app/_layout.tsx`): The Personal preferences editor (`app/(modals)/custom-system-prompt.tsx`) and App info. * **App info moved out of the drawer** into Settings (`components/drawer/DrawerMenu.tsx`), alongside Personal preferences via a shared `SettingsRow` (`components/settings/SettingsRow.tsx`). * **Drawer active-item logic fixed**: Drawer destinations use `replace` + path-equality highlighting; modal actions no longer show a stale active state. #### Input Error State * **Animated border** (`components/TextInputBorder.tsx`): Crossfades between inactive / active / error states via opacity (native driver), with error taking precedence over active. * **TextAreaField gains error state** (`components/TextAreaField.tsx`): Added `error?: boolean` + `errorMessage?: string` props. Renders a red border and an inline message only when set — no layout change otherwise. The preferences editor uses it to flag the over-limit case. #### Chore * **Deprecation cleanup**: Replaced `StyleSheet.absoluteFillObject` (RN 0.83 `@deprecated`) with `StyleSheet.absoluteFill` across 4 components — same frozen object, no behavior change.
1 parent be7c03b commit 1f6e8a1

21 files changed

Lines changed: 612 additions & 46 deletions

__tests__/DrawerMenu.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,12 @@ beforeEach(() => {
9696
afterEach(() => setPlatform(ORIGINAL_OS));
9797

9898
describe('DrawerMenu — collapsed', () => {
99-
it('keeps New chat, Models and App Info at the top', () => {
99+
it('keeps New chat, Models and Settings at the top', () => {
100100
renderMenu();
101101

102102
expect(screen.getByText('New chat')).toBeTruthy();
103103
expect(screen.getByText('Models')).toBeTruthy();
104-
expect(screen.getByText('App Info')).toBeTruthy();
104+
expect(screen.getByText('Settings')).toBeTruthy();
105105
});
106106

107107
it('renders the app name and a search button instead of a search field', () => {
@@ -225,15 +225,15 @@ describe('DrawerMenu — searching', () => {
225225

226226
expect(screen.getByText('New chat')).toBeTruthy();
227227
expect(screen.getByText('Models')).toBeTruthy();
228-
expect(screen.getByText('App Info')).toBeTruthy();
228+
expect(screen.getByText('Settings')).toBeTruthy();
229229
});
230230

231231
it('hides the navigation items once a query is typed, leaving only results', () => {
232232
renderMenu({ searching: true, search: 'pizza' });
233233

234234
expect(screen.queryByText('New chat')).toBeNull();
235235
expect(screen.queryByText('Models')).toBeNull();
236-
expect(screen.queryByText('App Info')).toBeNull();
236+
expect(screen.queryByText('Settings')).toBeNull();
237237
expect(screen.getByText('Pizza recipe')).toBeTruthy();
238238
});
239239

__tests__/llmStore.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { LLMModule } from 'react-native-executorch';
33
import * as chatRepository from '../database/chatRepository';
44
import * as Feedback from '../utils/Feedback';
55
import { prepareMessagesForLLM } from '../utils/promptUtils';
6+
import { useSettingsStore } from '../store/settingsStore';
67

78
jest.mock('../database/chatRepository');
89
jest.mock('../utils/Feedback', () => ({
@@ -75,6 +76,10 @@ beforeEach(() => {
7576
activeChatMessages: [],
7677
});
7778

79+
// Default: settings already hydrated, so the hydration barrier is a no-op
80+
// for every test except the cold-start one below (which opts into false).
81+
useSettingsStore.setState({ hasHydrated: true, customSystemPrompt: '' });
82+
7883
jest.clearAllMocks();
7984
jest.spyOn(console, 'error').mockImplementation(() => {});
8085
jest.spyOn(console, 'warn').mockImplementation(() => {});
@@ -432,6 +437,60 @@ describe('sendChatMessage', () => {
432437
});
433438
});
434439

440+
describe('sendChatMessage — settings hydration barrier', () => {
441+
const settings = { systemPrompt: 'be helpful' };
442+
443+
beforeEach(async () => {
444+
await loadModel();
445+
mockPersistMessage.mockResolvedValue(42);
446+
mockInstance.generate.mockResolvedValue('response');
447+
useLLMStore.setState({
448+
model: baseModel,
449+
activeChatId: 1,
450+
activeChatMessages: [],
451+
});
452+
});
453+
454+
it('does not read customSystemPrompt until settings have hydrated (cold-start race)', async () => {
455+
useSettingsStore.setState({ hasHydrated: false, customSystemPrompt: '' });
456+
457+
const sendPromise = useLLMStore
458+
.getState()
459+
.sendChatMessage('hello', 1, [], settings);
460+
461+
await new Promise((resolve) => setTimeout(resolve, 10));
462+
463+
expect(prepareMessagesForLLM).not.toHaveBeenCalled();
464+
expect(mockInstance.generate).not.toHaveBeenCalled();
465+
466+
useSettingsStore.setState({
467+
hasHydrated: true,
468+
customSystemPrompt: 'Always end replies with BANANA',
469+
});
470+
471+
await sendPromise;
472+
473+
expect(prepareMessagesForLLM).toHaveBeenCalledTimes(1);
474+
expect((prepareMessagesForLLM as jest.Mock).mock.calls[0][4]).toBe(
475+
'Always end replies with BANANA'
476+
);
477+
});
478+
479+
it('reads customSystemPrompt immediately when settings are already hydrated', async () => {
480+
useSettingsStore.setState({
481+
hasHydrated: true,
482+
customSystemPrompt: 'Be concise.',
483+
});
484+
485+
await useLLMStore.getState().sendChatMessage('hi', 1, [], settings);
486+
487+
expect(prepareMessagesForLLM).toHaveBeenCalledTimes(1);
488+
expect((prepareMessagesForLLM as jest.Mock).mock.calls[0][4]).toBe(
489+
'Be concise.'
490+
);
491+
});
492+
});
493+
435494
// ─── sendEventMessage ─────────────────────────────────────────────────────────
436495

437496
describe('sendEventMessage', () => {

__tests__/promptUtils.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,83 @@ describe('prepareMessagesForLLM', () => {
7777
});
7878
});
7979

80+
describe('global custom system prompt', () => {
81+
it('appends the global custom prompt to the base system prompt', () => {
82+
const messages = makeMessages(2);
83+
const result = prepareMessagesForLLM(
84+
messages,
85+
[],
86+
baseSettings,
87+
baseModel,
88+
'Always answer in Polish.'
89+
);
90+
expect(result[0].content).toContain(baseSettings.systemPrompt);
91+
expect(result[0].content).toContain('Always answer in Polish.');
92+
});
93+
94+
it('frames the custom prompt as silent guidance so the model does not parrot it', () => {
95+
const messages = makeMessages(2);
96+
const result = prepareMessagesForLLM(
97+
messages,
98+
[],
99+
baseSettings,
100+
baseModel,
101+
'Always answer in Polish.'
102+
);
103+
expect(result[0].content).toMatch(/silently/i);
104+
expect(result[0].content).toMatch(/never mention/i);
105+
expect(result[0].content.indexOf('silently')).toBeLessThan(
106+
result[0].content.indexOf('Always answer in Polish.')
107+
);
108+
});
109+
110+
it('keeps the base prompt unchanged when the global prompt is empty or whitespace', () => {
111+
const messages = makeMessages(2);
112+
const emptyResult = prepareMessagesForLLM(
113+
messages,
114+
[],
115+
baseSettings,
116+
baseModel,
117+
''
118+
);
119+
const whitespaceResult = prepareMessagesForLLM(
120+
messages,
121+
[],
122+
baseSettings,
123+
baseModel,
124+
' \n '
125+
);
126+
expect(emptyResult[0].content).toBe(baseSettings.systemPrompt);
127+
expect(whitespaceResult[0].content).toBe(baseSettings.systemPrompt);
128+
});
129+
130+
it('uses the global prompt alone when the base system prompt is empty', () => {
131+
const messages = makeMessages(2);
132+
const result = prepareMessagesForLLM(
133+
messages,
134+
[],
135+
{ ...baseSettings, systemPrompt: '' },
136+
baseModel,
137+
'Be concise.'
138+
);
139+
expect(result[0].content).toContain('Be concise.');
140+
});
141+
142+
it('preserves the RAG grounding instructions alongside the global prompt', () => {
143+
const messages = makeMessages(2);
144+
const result = prepareMessagesForLLM(
145+
messages,
146+
['some context'],
147+
baseSettings,
148+
baseModel,
149+
'Always answer in Polish.'
150+
);
151+
expect(result[0].content).toContain('You are a helpful assistant.');
152+
expect(result[0].content).toContain('Always answer in Polish.');
153+
expect(result[0].content).toContain('IMPORTANT CONTEXT INFORMATION');
154+
});
155+
});
156+
80157
describe('event message filtering', () => {
81158
it('strips event messages from the output', () => {
82159
// Last item is the empty assistant placeholder (as per llmStore contract)

__tests__/settingsStore.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useSettingsStore } from '../store/settingsStore';
2+
import { MAX_CUSTOM_SYSTEM_PROMPT_LENGTH } from '../constants/settings';
3+
4+
describe('useSettingsStore', () => {
5+
beforeEach(() => {
6+
useSettingsStore.setState({ customSystemPrompt: '' });
7+
});
8+
9+
it('defaults to an empty custom system prompt', () => {
10+
expect(useSettingsStore.getState().customSystemPrompt).toBe('');
11+
});
12+
13+
it('stores a custom system prompt', () => {
14+
useSettingsStore.getState().setCustomSystemPrompt('Be concise.');
15+
expect(useSettingsStore.getState().customSystemPrompt).toBe('Be concise.');
16+
});
17+
18+
it('trims surrounding whitespace', () => {
19+
useSettingsStore.getState().setCustomSystemPrompt(' Be concise. ');
20+
expect(useSettingsStore.getState().customSystemPrompt).toBe('Be concise.');
21+
});
22+
23+
it('clamps a prompt longer than the limit', () => {
24+
const tooLong = 'a'.repeat(MAX_CUSTOM_SYSTEM_PROMPT_LENGTH + 50);
25+
useSettingsStore.getState().setCustomSystemPrompt(tooLong);
26+
expect(useSettingsStore.getState().customSystemPrompt).toHaveLength(
27+
MAX_CUSTOM_SYSTEM_PROMPT_LENGTH
28+
);
29+
});
30+
31+
it('keeps a prompt at exactly the limit intact', () => {
32+
const exact = 'a'.repeat(MAX_CUSTOM_SYSTEM_PROMPT_LENGTH);
33+
useSettingsStore.getState().setCustomSystemPrompt(exact);
34+
expect(useSettingsStore.getState().customSystemPrompt).toBe(exact);
35+
});
36+
});

app/(drawer)/_layout.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ const DrawerLayout = () => {
4343
title: 'Models',
4444
}}
4545
/>
46+
<Drawer.Screen
47+
name="settings"
48+
options={{
49+
title: 'Settings',
50+
}}
51+
/>
4652
<Drawer.Screen
4753
name="benchmark"
4854
options={{

app/(drawer)/settings.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import React, { useMemo } from 'react';
2+
import { StyleSheet, View } from 'react-native';
3+
import { ScrollView } from 'react-native-gesture-handler';
4+
import { useRouter } from 'expo-router';
5+
import useDefaultHeader from '../../hooks/useDefaultHeader';
6+
import { SettingsRow } from '../../components/settings/SettingsRow';
7+
import EditIcon from '../../assets/icons/edit.svg';
8+
import InfoCircleIcon from '../../assets/icons/info-circle.svg';
9+
import { useTheme } from '../../context/ThemeContext';
10+
import { Theme } from '../../styles/colors';
11+
12+
const SettingsScreen = () => {
13+
useDefaultHeader();
14+
const router = useRouter();
15+
const { theme } = useTheme();
16+
const styles = useMemo(() => createStyles(theme), [theme]);
17+
18+
return (
19+
<View style={styles.container}>
20+
<ScrollView
21+
contentContainerStyle={styles.scrollContent}
22+
showsVerticalScrollIndicator={false}
23+
>
24+
<SettingsRow
25+
label="Personal preferences"
26+
icon={<EditIcon width={20} height={20} style={styles.rowIcon} />}
27+
onPress={() => router.push('/custom-system-prompt')}
28+
/>
29+
<SettingsRow
30+
label="App info"
31+
icon={
32+
<InfoCircleIcon width={20} height={20} style={styles.rowIcon} />
33+
}
34+
onPress={() => router.push('/app-info')}
35+
/>
36+
</ScrollView>
37+
</View>
38+
);
39+
};
40+
41+
export default SettingsScreen;
42+
43+
const createStyles = (theme: Theme) =>
44+
StyleSheet.create({
45+
container: {
46+
flex: 1,
47+
backgroundColor: theme.bg.softPrimary,
48+
paddingTop: 16,
49+
},
50+
scrollContent: {
51+
gap: 8,
52+
paddingHorizontal: 16,
53+
paddingBottom: theme.insets.bottom + 16,
54+
},
55+
rowIcon: {
56+
color: theme.text.primary,
57+
},
58+
});

app/(modals)/app-info.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
Text,
66
TouchableOpacity,
77
Linking,
8+
Platform,
89
} from 'react-native';
910
import { useRouter } from 'expo-router';
1011
import * as Clipboard from 'expo-clipboard';
@@ -130,8 +131,20 @@ export default function AppInfoScreen() {
130131

131132
return (
132133
<View style={[styles.screen, { backgroundColor: theme.bg.softPrimary }]}>
133-
<View style={[styles.container, { paddingBottom: insets.bottom }]}>
134-
<ModalHeader title="App info" onClose={() => router.back()} />
134+
<View
135+
style={[
136+
styles.container,
137+
{
138+
paddingTop: Platform.OS === 'ios' ? insets.top : 16,
139+
paddingBottom: insets.bottom,
140+
},
141+
]}
142+
>
143+
<ModalHeader
144+
title="App info"
145+
leftIcon="back"
146+
onClose={() => router.back()}
147+
/>
135148
<AppHeader />
136149
<LearnMoreSection />
137150
<VersionInfo />

0 commit comments

Comments
 (0)