From 7303d568f4d392dbddd34bca1fb2645c218d1600 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Wed, 4 Mar 2026 23:51:24 -0300 Subject: [PATCH] feat: Implement local chat saving and history management using Zustand and AsyncStorage. --- app/chat/_layout.tsx | 26 +++-- app/chat/history.tsx | 124 +++++++++++++++++++++++ app/settings/index.tsx | 5 + docs/PLAN-local-save-chats.md | 91 +++++++++++++++++ hooks/useLMStudio.ts | 16 ++- package-lock.json | 34 +++++++ package.json | 1 + store/useAppStore.ts | 180 ++++++++++++++++++++++++---------- 8 files changed, 412 insertions(+), 65 deletions(-) create mode 100644 app/chat/history.tsx create mode 100644 docs/PLAN-local-save-chats.md diff --git a/app/chat/_layout.tsx b/app/chat/_layout.tsx index 6d43cf5..291f819 100644 --- a/app/chat/_layout.tsx +++ b/app/chat/_layout.tsx @@ -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: () => ( - - - + + router.push('/chat/history')} + style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]} + activeOpacity={0.7} + > + + + + + + ), headerRight: () => ( + diff --git a/app/chat/history.tsx b/app/chat/history.tsx new file mode 100644 index 0000000..afca7a3 --- /dev/null +++ b/app/chat/history.tsx @@ -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 ( + + + router.back()} style={styles.backButton}> + + + Chat History + + + {chatSessions.length === 0 ? ( + + + No saved chats yet. + + ) : ( + item.id} + contentContainerStyle={styles.listContent} + renderItem={({ item }: { item: ChatSession }) => ( + handleLoadChat(item.id)} + > + + + {item.title} + + + {new Date(item.updatedAt).toLocaleString()} · {item.messages.length} msgs + + + deleteChatSession(item.id)} + > + + + + )} + /> + )} + + ); +} + +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, + }, +}); diff --git a/app/settings/index.tsx b/app/settings/index.tsx index 4ddc5b4..8ae4ead 100644 --- a/app/settings/index.tsx +++ b/app/settings/index.tsx @@ -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); }; diff --git a/docs/PLAN-local-save-chats.md b/docs/PLAN-local-save-chats.md new file mode 100644 index 0000000..d995bd5 --- /dev/null +++ b/docs/PLAN-local-save-chats.md @@ -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 diff --git a/hooks/useLMStudio.ts b/hooks/useLMStudio.ts index e58d3ca..436a3ec 100644 --- a/hooks/useLMStudio.ts +++ b/hooks/useLMStudio.ts @@ -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]); diff --git a/package-lock.json b/package-lock.json index ae6c675..950d712 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index dd4e16c..d308129 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/store/useAppStore.ts b/store/useAppStore.ts index a902317..d796d1f 100644 --- a/store/useAppStore.ts +++ b/store/useAppStore.ts @@ -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((set, get) => ({ - theme: 'system', - setTheme: (theme) => set({ theme }), +export const useAppStore = create()( + 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, + }), + } + ) +);