feat: Implement local chat saving and history management using Zustand and AsyncStorage.
This commit is contained in:
1 parent
5fd7f7d2ed
commit
7303d568f4
8 files changed
+412
-65
No files matched your search
+18
-8
@@ -2,7 +2,7 @@ import { ModelSelector } from '@/components/chat/ModelSelector';
|
|||||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||||
import { useAppStore } from '@/store/useAppStore';
|
import { useAppStore } from '@/store/useAppStore';
|
||||||
import { Stack, useRouter } from 'expo-router';
|
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';
|
import { Platform, StyleSheet, TouchableOpacity, useWindowDimensions, View } from 'react-native';
|
||||||
|
|
||||||
export default function ChatLayout() {
|
export default function ChatLayout() {
|
||||||
@@ -33,13 +33,22 @@ export default function ChatLayout() {
|
|||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerStyle: { backgroundColor: colors.backgroundColor },
|
headerStyle: { backgroundColor: colors.backgroundColor },
|
||||||
headerLeft: () => (
|
headerLeft: () => (
|
||||||
<TouchableOpacity
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||||
onPress={clearChat}
|
<TouchableOpacity
|
||||||
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
|
onPress={() => router.push('/chat/history')}
|
||||||
activeOpacity={0.7}
|
style={[styles.headerBtn, { backgroundColor: colors.cardBackground }]}
|
||||||
>
|
activeOpacity={0.7}
|
||||||
<Plus size={22} color={colors.text} />
|
>
|
||||||
</TouchableOpacity>
|
<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: () => (
|
headerRight: () => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -52,6 +61,7 @@ export default function ChatLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen name="history" options={{ headerShown: false, presentation: 'modal' }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
|
import { ConnectionBadge } from '@/components/ui/ConnectionBadge';
|
||||||
|
import { useLMStudio } from '@/hooks/useLMStudio';
|
||||||
import { useThemeColor } from '@/hooks/useThemeColor';
|
import { useThemeColor } from '@/hooks/useThemeColor';
|
||||||
import * as api from '@/services/lmStudioApi';
|
import * as api from '@/services/lmStudioApi';
|
||||||
import { ServerConfig } from '@/services/types';
|
import { ServerConfig } from '@/services/types';
|
||||||
@@ -28,6 +29,7 @@ export default function SettingsScreen() {
|
|||||||
const setServerConfig = useAppStore(state => state.setServerConfig);
|
const setServerConfig = useAppStore(state => state.setServerConfig);
|
||||||
const isConnected = useAppStore(state => state.isConnected);
|
const isConnected = useAppStore(state => state.isConnected);
|
||||||
const setIsConnected = useAppStore(state => state.setIsConnected);
|
const setIsConnected = useAppStore(state => state.setIsConnected);
|
||||||
|
const { fetchModels } = useLMStudio();
|
||||||
|
|
||||||
// Local State
|
// Local State
|
||||||
const [host, setHost] = useState(serverConfig.host);
|
const [host, setHost] = useState(serverConfig.host);
|
||||||
@@ -59,6 +61,9 @@ export default function SettingsScreen() {
|
|||||||
const ok = await api.testConnection(currentConfig);
|
const ok = await api.testConnection(currentConfig);
|
||||||
setTestResult(ok);
|
setTestResult(ok);
|
||||||
setIsConnected(ok);
|
setIsConnected(ok);
|
||||||
|
if (ok) {
|
||||||
|
await fetchModels();
|
||||||
|
}
|
||||||
setIsTesting(false);
|
setIsTesting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -28,7 +28,8 @@ export function useLMStudio() {
|
|||||||
|
|
||||||
const fetchModels = useCallback(async () => {
|
const fetchModels = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const models = await api.getModels(serverConfig);
|
const currentConfig = useAppStore.getState().serverConfig;
|
||||||
|
const models = await api.getModels(currentConfig);
|
||||||
setAvailableModels(models);
|
setAvailableModels(models);
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export function useLMStudio() {
|
|||||||
setAvailableModels([]);
|
setAvailableModels([]);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [serverConfig, activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
|
}, [activeModelId, setAvailableModels, setIsConnected, setActiveModelId]);
|
||||||
|
|
||||||
const sendMessage = useCallback(async (text: string) => {
|
const sendMessage = useCallback(async (text: string) => {
|
||||||
if (isStreaming) return;
|
if (isStreaming) return;
|
||||||
@@ -80,18 +81,21 @@ export function useLMStudio() {
|
|||||||
() => {
|
() => {
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
|
useAppStore.getState().saveCurrentChat();
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
const errorMsg = `\n\n⚠️ Error: ${error.message}`;
|
const errorMsg = `\n\n⚠️ Error: ${error.message}`;
|
||||||
appendToMessage(assistantId, errorMsg);
|
appendToMessage(assistantId, errorMsg);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
|
useAppStore.getState().saveCurrentChat();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMsg = `⚠️ Connection failed: ${(err as Error).message}`;
|
const errorMsg = `⚠️ Connection failed: ${(err as Error).message}`;
|
||||||
appendToMessage(assistantId, errorMsg);
|
appendToMessage(assistantId, errorMsg);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
|
useAppStore.getState().saveCurrentChat();
|
||||||
}
|
}
|
||||||
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
|
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
|
||||||
|
|
||||||
@@ -136,16 +140,22 @@ export function useLMStudio() {
|
|||||||
modelId,
|
modelId,
|
||||||
apiMessages,
|
apiMessages,
|
||||||
(chunk) => appendToMessage(assistantId, chunk),
|
(chunk) => appendToMessage(assistantId, chunk),
|
||||||
() => { setIsStreaming(false); abortRef.current = null; },
|
() => {
|
||||||
|
setIsStreaming(false);
|
||||||
|
abortRef.current = null;
|
||||||
|
useAppStore.getState().saveCurrentChat();
|
||||||
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
|
appendToMessage(assistantId, `\n\n⚠️ Error: ${error.message}`);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
|
useAppStore.getState().saveCurrentChat();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
appendToMessage(assistantId, `⚠️ Connection failed: ${(err as Error).message}`);
|
appendToMessage(assistantId, `⚠️ Connection failed: ${(err as Error).message}`);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
|
useAppStore.getState().saveCurrentChat();
|
||||||
}
|
}
|
||||||
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
|
}, [isStreaming, serverConfig, addMessage, appendToMessage, setIsStreaming]);
|
||||||
|
|
||||||
|
|||||||
Generated
+34
@@ -11,6 +11,7 @@
|
|||||||
"@babel/plugin-transform-runtime": "^7.29.0",
|
"@babel/plugin-transform-runtime": "^7.29.0",
|
||||||
"@babel/runtime": "^7.28.6",
|
"@babel/runtime": "^7.28.6",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@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": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
"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"
|
"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": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"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==",
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@babel/plugin-transform-runtime": "^7.29.0",
|
"@babel/plugin-transform-runtime": "^7.29.0",
|
||||||
"@babel/runtime": "^7.28.6",
|
"@babel/runtime": "^7.28.6",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
|||||||
+126
-54
@@ -1,4 +1,6 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||||
import { DEFAULT_SERVER_CONFIG, LMModel, ServerConfig } from '../services/types';
|
import { DEFAULT_SERVER_CONFIG, LMModel, ServerConfig } from '../services/types';
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
@@ -9,6 +11,13 @@ export interface ChatMessage {
|
|||||||
isEdited?: boolean;
|
isEdited?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatSession {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
updatedAt: number;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// Theme
|
// Theme
|
||||||
theme: 'light' | 'dark' | 'system';
|
theme: 'light' | 'dark' | 'system';
|
||||||
@@ -43,63 +52,126 @@ interface AppState {
|
|||||||
// Edit mode
|
// Edit mode
|
||||||
editingMessageId: string | null;
|
editingMessageId: string | null;
|
||||||
setEditingMessageId: (id: string | null) => void;
|
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) => ({
|
export const useAppStore = create<AppState>()(
|
||||||
theme: 'system',
|
persist(
|
||||||
setTheme: (theme) => set({ theme }),
|
(set, get) => ({
|
||||||
|
theme: 'system',
|
||||||
|
setTheme: (theme) => set({ theme }),
|
||||||
|
|
||||||
serverConfig: DEFAULT_SERVER_CONFIG,
|
serverConfig: DEFAULT_SERVER_CONFIG,
|
||||||
setServerConfig: (serverConfig) => set({ serverConfig }),
|
setServerConfig: (serverConfig) => set({ serverConfig }),
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
setIsConnected: (isConnected) => set({ isConnected }),
|
setIsConnected: (isConnected) => set({ isConnected }),
|
||||||
|
|
||||||
activeModelId: '',
|
activeModelId: '',
|
||||||
setActiveModelId: (activeModelId) => set({ activeModelId }),
|
setActiveModelId: (activeModelId) => set({ activeModelId }),
|
||||||
availableModels: [],
|
availableModels: [],
|
||||||
setAvailableModels: (availableModels) => set({ availableModels }),
|
setAvailableModels: (availableModels) => set({ availableModels }),
|
||||||
|
|
||||||
messages: [],
|
messages: [],
|
||||||
addMessage: (msg) => {
|
addMessage: (msg) => {
|
||||||
const id = Date.now().toString() + Math.random().toString(36).slice(2, 6);
|
const id = Date.now().toString() + Math.random().toString(36).slice(2, 6);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messages: [...state.messages, { ...msg, id, timestamp: Date.now() }],
|
messages: [...state.messages, { ...msg, id, timestamp: Date.now() }],
|
||||||
}));
|
}));
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
updateMessageContent: (id, content) =>
|
updateMessageContent: (id, content) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messages: state.messages.map((m) =>
|
messages: state.messages.map((m) =>
|
||||||
m.id === id ? { ...m, content } : m,
|
m.id === id ? { ...m, content } : m,
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
appendToMessage: (id, chunk) =>
|
appendToMessage: (id, chunk) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messages: state.messages.map((m) =>
|
messages: state.messages.map((m) =>
|
||||||
m.id === id ? { ...m, content: m.content + chunk } : m,
|
m.id === id ? { ...m, content: m.content + chunk } : m,
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
deleteMessage: (id) =>
|
deleteMessage: (id) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messages: state.messages.filter((m) => m.id !== id),
|
messages: state.messages.filter((m) => m.id !== id),
|
||||||
})),
|
})),
|
||||||
deleteMessagesAfter: (id) =>
|
deleteMessagesAfter: (id) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const idx = state.messages.findIndex((m) => m.id === id);
|
const idx = state.messages.findIndex((m) => m.id === id);
|
||||||
if (idx === -1) return state;
|
if (idx === -1) return state;
|
||||||
return { messages: state.messages.slice(0, idx + 1) };
|
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) => ({
|
name: 'localaiapp-storage',
|
||||||
messages: state.messages.map((m) =>
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
m.id === id ? { ...m, content, isEdited: true } : m,
|
partialize: (state) => ({
|
||||||
),
|
theme: state.theme,
|
||||||
})),
|
serverConfig: state.serverConfig,
|
||||||
clearChat: () => set({ messages: [] }),
|
chatSessions: state.chatSessions,
|
||||||
|
messages: state.messages,
|
||||||
isStreaming: false,
|
currentChatId: state.currentChatId,
|
||||||
setIsStreaming: (isStreaming) => set({ isStreaming }),
|
}),
|
||||||
|
}
|
||||||
editingMessageId: null,
|
)
|
||||||
setEditingMessageId: (editingMessageId) => set({ editingMessageId }),
|
);
|
||||||
}));
|
|
||||||
Reference in new issue
Block a user