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 { 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>
|
||||
|
||||
@@ -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 { 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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
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]);
|
||||
|
||||
|
||||
Generated
+34
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in new issue
Block a user