feat: Implement local chat saving and history management using Zustand and AsyncStorage.

This commit is contained in:
Evandro Fonseca Junior committed 2026-03-04 23:51:24 -03:00
1 parent 5fd7f7d2ed
commit 7303d568f4
8 files changed
+412 -65

No files matched your search

+18 -8
View File
@@ -2,7 +2,7 @@ import { ModelSelector } from '@/components/chat/ModelSelector';
import { useThemeColor } from '@/hooks/useThemeColor';
import { useAppStore } from '@/store/useAppStore';
import { Stack, useRouter } from 'expo-router';
import { Plus, Settings } from 'lucide-react-native';
import { History, Plus, Settings } from 'lucide-react-native';
import { Platform, StyleSheet, TouchableOpacity, useWindowDimensions, View } from 'react-native';
export default function ChatLayout() {
@@ -33,13 +33,22 @@ export default function ChatLayout() {
headerShadowVisible: false,
headerStyle: { backgroundColor: colors.backgroundColor },
headerLeft: () => (
<TouchableOpacity
onPress={clearChat}
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7}
>
<Plus size={22} color={colors.text} />
</TouchableOpacity>
<View style={{ flexDirection: 'row', gap: 8 }}>
<TouchableOpacity
onPress={() => router.push('/chat/history')}
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7}
>
<History size={22} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity
onPress={clearChat}
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
activeOpacity={0.7}
>
<Plus size={22} color={colors.text} />
</TouchableOpacity>
</View>
),
headerRight: () => (
<TouchableOpacity
@@ -52,6 +61,7 @@ export default function ChatLayout() {
),
}}
/>
<Stack.Screen name="history" options={{ headerShown: false, presentation: 'modal' }} />
</Stack>
</View>
</View>
+124
View File
@@ -0,0 +1,124 @@
import { useThemeColor } from '@/hooks/useThemeColor';
import { ChatSession, useAppStore } from '@/store/useAppStore';
import { useRouter } from 'expo-router';
import { ArrowLeft, MessageSquare, Trash2 } from 'lucide-react-native';
import React from 'react';
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
export default function HistoryScreen() {
const colors = useThemeColor();
const router = useRouter();
const chatSessions = useAppStore(state => state.chatSessions);
const loadChat = useAppStore(state => state.loadChat);
const deleteChatSession = useAppStore(state => state.deleteChatSession);
const handleLoadChat = (id: string) => {
loadChat(id);
router.back();
};
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}>
<ArrowLeft size={24} color={colors.text} />
</TouchableOpacity>
<Text style={[styles.title, { color: colors.text }]}>Chat History</Text>
</View>
{chatSessions.length === 0 ? (
<View style={styles.emptyState}>
<MessageSquare size={48} color={colors.textMuted} />
<Text style={[styles.emptyText, { color: colors.textMuted }]}>No saved chats yet.</Text>
</View>
) : (
<FlatList
data={chatSessions}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.listContent}
renderItem={({ item }: { item: ChatSession }) => (
<TouchableOpacity
style={[
styles.chatItem,
{ backgroundColor: colors.cardBackground, borderColor: colors.border }
]}
onPress={() => handleLoadChat(item.id)}
>
<View style={styles.chatInfo}>
<Text style={[styles.chatTitle, { color: colors.text }]} numberOfLines={1}>
{item.title}
</Text>
<Text style={[styles.chatDate, { color: colors.textMuted }]}>
{new Date(item.updatedAt).toLocaleString()} · {item.messages.length} msgs
</Text>
</View>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => deleteChatSession(item.id)}
>
<Trash2 size={20} color={colors.errorText} />
</TouchableOpacity>
</TouchableOpacity>
)}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: 1,
},
backButton: {
marginRight: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
emptyState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 32,
},
emptyText: {
marginTop: 16,
fontSize: 16,
},
listContent: {
padding: 16,
gap: 12,
},
chatItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderRadius: 12,
borderWidth: 1,
},
chatInfo: {
flex: 1,
marginRight: 16,
},
chatTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
chatDate: {
fontSize: 12,
},
deleteButton: {
padding: 8,
},
});
+5
View File
@@ -1,4 +1,5 @@
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
import { useLMStudio } from '@/hooks/useLMStudio';
import { useThemeColor } from '@/hooks/useThemeColor';
import * as api from '@/services/lmStudioApi';
import { ServerConfig } from '@/services/types';
@@ -28,6 +29,7 @@ export default function SettingsScreen() {
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);
@@ -59,6 +61,9 @@ export default function SettingsScreen() {
const ok = await api.testConnection(currentConfig);
setTestResult(ok);
setIsConnected(ok);
if (ok) {
await fetchModels();
}
setIsTesting(false);
};
+91
View File
@@ -0,0 +1,91 @@
# Local Save and Auto-Refresh Models Plan
## 1. Overview
The goal is to implement local data persistence for chats, messages, and server connection settings (IP/Port/API Key), alongside an improved connection workflow that automatically fetches available models upon a successful connection. Finally, a new "Saved Chats" area will be introduced to browse and manage historical conversations.
## 2. Project Type
**WEB/MOBILE** (Cross-platform Expo/React Native app)
## 3. Success Criteria
- [ ] Server connection settings (Host, Port) persist across app restarts.
- [ ] Chat sessions and their respective messages are saved locally.
- [ ] Users can view and load previous chats from a new "Saved Chats" screen/drawer.
- [ ] Upon successful connection in Settings, the app automatically fetches and populates the available models list.
- [ ] Zero data loss between soft reloads.
## 4. Tech Stack
- **State Management & Persistence:** `zustand` + `use-sync-external-store` with React Native Async Storage OR Expo SQLite (decision needed, starting with AsyncStorage via Zustand persist middleware for simplicity).
- **Navigation:** Expo Router (`app/(drawers)` or `app/chats/`).
- **UI:** NativeWind / StyleSheet with `Colors.ts` zinc theme.
## 5. File Structure Modifications
```text
├── app/
│ ├── chat/
│ │ ├── index.tsx (Update to handle loading specific chat IDs)
│ │ └── history.tsx [NEW] (Screen for Saved Chats)
│ └── settings/
│ └── index.tsx (Update to trigger model refresh on connect)
├── store/
│ └── useAppStore.ts (Add persist middleware, add chat history management)
└── components/
└── chat/
└── ChatHistoryItem.tsx [NEW]
```
## 6. Task Breakdown
### Task 1: Setup Persistence in App Store
- **Agent:** `frontend-specialist`
- **Skills:** `react-best-practices`, `nodejs-best-practices`
- **Priority:** P1
- **INPUT:** `useAppStore.ts`
- **OUTPUT:** Zustand store wrapped in `persist` middleware using `AsyncStorage`.
- **VERIFY:** Reloading the app keeps the `serverConfig` and `messages` intact.
### Task 2: Implement Chat Sessions (Saved Chats) Data Structure
- **Agent:** `frontend-specialist`
- **Skills:** `react-best-practices`
- **Priority:** P1
- **INPUT:** `useAppStore.ts`
- **OUTPUT:** Add `ChatSession` type (id, title, updatedAt, messages). Create actions `saveCurrentChat`, `loadChat`, `deleteChat`.
- **VERIFY:** Calling `saveCurrentChat` creates a new entry in the persisted `chatSessions` array.
### Task 3: Auto-Refresh Models on Connection
- **Agent:** `frontend-specialist`
- **Skills:** `api-patterns`
- **Priority:** P2
- **INPUT:** `app/settings/index.tsx` and `hooks/useLMStudio.ts`
- **OUTPUT:** When `testConnection` succeeds, automatically call `fetchModels` and update the active model if none is selected.
- **VERIFY:** Connecting to a valid server immediately makes the Model selector functional without requiring a manual refresh click.
### Task 4: Create "Saved Chats" UI Screen
- **Agent:** `frontend-specialist`
- **Skills:** `frontend-design`
- **Priority:** P2
- **INPUT:** `app/chat/history.tsx` (new)
- **OUTPUT:** A screen listing all saved chats, sorted by `updatedAt` desc, allowing users to tap to load or swipe/click to delete.
- **VERIFY:** Screen renders correctly, respects dark mode (zinc), and loading a chat correctly populates the Chat screen.
### Task 5: Integrate Save Button / Auto-Save in Chat Screen
- **Agent:** `frontend-specialist`
- **Skills:** `frontend-design`
- **Priority:** P3
- **INPUT:** `app/chat/index.tsx`
- **OUTPUT:** Header button or automatic mechanism to name and save the current active chat session to history.
- **VERIFY:** User can explicitly snapshot their current conversation.
## 7. Phase X: Verification
- [x] **Lint:** Pass `npm run lint`
- [x] **Types:** Pass `npx tsc --noEmit`
- [x] **Functionality:**
- Kill the app and reopen -> Connection IP should remaining.
- Connect -> Models load instantly.
- Chat -> Save -> Open History -> Chat is there.
## ✅ PHASE X COMPLETE
- Lint: ✅ Pass
- Security: ✅ No critical issues
- Build: ✅ Success
- Date: 2026-03-04
+13 -3
View File
@@ -28,7 +28,8 @@ export function useLMStudio() {
const fetchModels = useCallback(async () => {
try {
const models = await api.getModels(serverConfig);
const currentConfig = useAppStore.getState().serverConfig;
const models = await api.getModels(currentConfig);
setAvailableModels(models);
setIsConnected(true);
@@ -41,7 +42,7 @@ export function useLMStudio() {
setAvailableModels([]);
return [];
}
}, [serverConfig, activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
}, [activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
const sendMessage = useCallback(async (text: string) => {
if (isStreaming) return;
@@ -80,18 +81,21 @@ export function useLMStudio() {
() => {
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);
setIsStreaming(false);
useAppStore.getState().saveCurrentChat();
}
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
@@ -136,16 +140,22 @@ export function useLMStudio() {
modelId,
apiMessages,
(chunk) => appendToMessage(assistantId, chunk),
() => { setIsStreaming(false); abortRef.current = null; },
() => {
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
},
(error) => {
appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
setIsStreaming(false);
abortRef.current = null;
useAppStore.getState().saveCurrentChat();
},
);
} catch (err) {
appendToMessage(assistantId, `⚠️ Connection failed: ${(err as Error).message}`);
setIsStreaming(false);
useAppStore.getState().saveCurrentChat();
}
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
+34
View File
@@ -11,6 +11,7 @@
"@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",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
@@ -2797,6 +2798,18 @@
}
}
},
"node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
"license": "MIT",
"dependencies": {
"merge-options": "^3.0.4"
},
"peerDependencies": {
"react-native": "^0.0.0-0 || >=0.65 <1.0"
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -8018,6 +8031,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -9055,6 +9077,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/merge-options": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
"license": "MIT",
"dependencies": {
"is-plain-obj": "^2.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"@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",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
+126 -54
View File
@@ -1,4 +1,6 @@
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';
export interface ChatMessage {
@@ -9,6 +11,13 @@ export interface ChatMessage {
isEdited?: boolean;
}
export interface ChatSession {
id: string;
title: string;
updatedAt: number;
messages: ChatMessage[];
}
interface AppState {
// Theme
theme: 'light' | 'dark' | 'system';
@@ -43,63 +52,126 @@ interface AppState {
// Edit mode
editingMessageId: string | null;
setEditingMessageId: (id: string | null) => void;
// Chat Sessions
chatSessions: ChatSession[];
currentChatId: string | null;
setCurrentChatId: (id: string | null) => void;
saveCurrentChat: () => void;
loadChat: (id: string) => void;
deleteChatSession: (id: string) => void;
}
export const useAppStore = create<AppState>((set, get) => ({
theme: 'system',
setTheme: (theme) => set({ theme }),
export const useAppStore = create<AppState>()(
persist(
(set, get) => ({
theme: 'system',
setTheme: (theme) => set({ theme }),
serverConfig: DEFAULT_SERVER_CONFIG,
setServerConfig: (serverConfig) => set({ serverConfig }),
isConnected: false,
setIsConnected: (isConnected) => set({ isConnected }),
serverConfig: DEFAULT_SERVER_CONFIG,
setServerConfig: (serverConfig) => set({ serverConfig }),
isConnected: false,
setIsConnected: (isConnected) => set({ isConnected }),
activeModelId: '',
setActiveModelId: (activeModelId) => set({ activeModelId }),
availableModels: [],
setAvailableModels: (availableModels) => set({ availableModels }),
activeModelId: '',
setActiveModelId: (activeModelId) => set({ activeModelId }),
availableModels: [],
setAvailableModels: (availableModels) => set({ availableModels }),
messages: [],
addMessage: (msg) => {
const id = Date.now().toString() + Math.random().toString(36).slice(2, 6);
set((state) => ({
messages: [...state.messages, { ...msg, id, timestamp: Date.now() }],
}));
return id;
},
updateMessageContent: (id, content) =>
set((state) => ({
messages: state.messages.map((m) =>
m.id === id ? { ...m, content } : m,
),
})),
appendToMessage: (id, chunk) =>
set((state) => ({
messages: state.messages.map((m) =>
m.id === id ? { ...m, content: m.content + chunk } : m,
),
})),
deleteMessage: (id) =>
set((state) => ({
messages: state.messages.filter((m) => m.id !== id),
})),
deleteMessagesAfter: (id) =>
set((state) => {
const idx = state.messages.findIndex((m) => m.id === id);
if (idx === -1) return state;
return { messages: state.messages.slice(0, idx + 1) };
messages: [],
addMessage: (msg) => {
const id = Date.now().toString() + Math.random().toString(36).slice(2, 6);
set((state) => ({
messages: [...state.messages, { ...msg, id, timestamp: Date.now() }],
}));
return id;
},
updateMessageContent: (id, content) =>
set((state) => ({
messages: state.messages.map((m) =>
m.id === id ? { ...m, content } : m,
),
})),
appendToMessage: (id, chunk) =>
set((state) => ({
messages: state.messages.map((m) =>
m.id === id ? { ...m, content: m.content + chunk } : m,
),
})),
deleteMessage: (id) =>
set((state) => ({
messages: state.messages.filter((m) => m.id !== id),
})),
deleteMessagesAfter: (id) =>
set((state) => {
const idx = state.messages.findIndex((m) => m.id === id);
if (idx === -1) return state;
return { messages: state.messages.slice(0, idx + 1) };
}),
editMessage: (id, content) =>
set((state) => ({
messages: state.messages.map((m) =>
m.id === id ? { ...m, content, isEdited: true } : m,
),
})),
clearChat: () => set({ messages: [], currentChatId: null }),
isStreaming: false,
setIsStreaming: (isStreaming) => set({ isStreaming }),
editingMessageId: null,
setEditingMessageId: (editingMessageId) => set({ editingMessageId }),
chatSessions: [],
currentChatId: null,
setCurrentChatId: (currentChatId) => set({ currentChatId }),
saveCurrentChat: () => {
const { messages, currentChatId, chatSessions } = get();
if (messages.length === 0) return;
const firstUserMsg = messages.find((m) => m.role === 'user')?.content || 'New Chat';
const title = firstUserMsg.length > 40 ? firstUserMsg.substring(0, 40) + '...' : firstUserMsg;
const now = Date.now();
if (currentChatId) {
set({
chatSessions: chatSessions.map((session) =>
session.id === currentChatId
? { ...session, title, updatedAt: now, messages }
: session
),
});
} else {
const newId = Date.now().toString();
set({
chatSessions: [{ id: newId, title, updatedAt: now, messages }, ...chatSessions],
currentChatId: newId,
});
}
},
loadChat: (id) => {
const session = get().chatSessions.find((s) => s.id === id);
if (session) {
set({ messages: session.messages, currentChatId: id });
}
},
deleteChatSession: (id) => {
set((state) => ({
chatSessions: state.chatSessions.filter((s) => s.id !== id),
...(state.currentChatId === id ? { messages: [], currentChatId: null } : {}),
}));
},
}),
editMessage: (id, content) =>
set((state) => ({
messages: state.messages.map((m) =>
m.id === id ? { ...m, content, isEdited: true } : m,
),
})),
clearChat: () => set({ messages: [] }),
isStreaming: false,
setIsStreaming: (isStreaming) => set({ isStreaming }),
editingMessageId: null,
setEditingMessageId: (editingMessageId) => set({ editingMessageId }),
}));
{
name: 'localaiapp-storage',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
theme: state.theme,
serverConfig: state.serverConfig,
chatSessions: state.chatSessions,
messages: state.messages,
currentChatId: state.currentChatId,
}),
}
)
);