fix: avoid persisting threads and messages on local storage (#5249)

This commit is contained in:
Louis 2025-06-12 09:10:00 +07:00 committed by GitHub
parent 3accef8c92
commit 079f206044
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 306 additions and 328 deletions

View File

@ -1,7 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import { ThreadMessage } from '@janhq/core' import { ThreadMessage } from '@janhq/core'
import { createJSONStorage, persist } from 'zustand/middleware'
import { localStorageKey } from '@/constants/localStorage'
import { import {
createMessage, createMessage,
deleteMessage as deleteMessageExt, deleteMessage as deleteMessageExt,
@ -16,59 +14,51 @@ type MessageState = {
deleteMessage: (threadId: string, messageId: string) => void deleteMessage: (threadId: string, messageId: string) => void
} }
export const useMessages = create<MessageState>()( export const useMessages = create<MessageState>()((set, get) => ({
persist( messages: {},
(set, get) => ({ getMessages: (threadId) => {
messages: {}, return get().messages[threadId] || []
getMessages: (threadId) => { },
return get().messages[threadId] || [] setMessages: (threadId, messages) => {
set((state) => ({
messages: {
...state.messages,
[threadId]: messages,
}, },
setMessages: (threadId, messages) => { }))
set((state) => ({ },
messages: { addMessage: (message) => {
...state.messages, const currentAssistant = useAssistant.getState().currentAssistant
[threadId]: messages, const newMessage = {
}, ...message,
})) created_at: message.created_at || Date.now(),
metadata: {
...message.metadata,
assistant: currentAssistant,
}, },
addMessage: (message) => {
const currentAssistant = useAssistant.getState().currentAssistant
const newMessage = {
...message,
created_at: message.created_at || Date.now(),
metadata: {
...message.metadata,
assistant: currentAssistant,
},
}
createMessage(newMessage).then((createdMessage) => {
set((state) => ({
messages: {
...state.messages,
[message.thread_id]: [
...(state.messages[message.thread_id] || []),
createdMessage,
],
},
}))
})
},
deleteMessage: (threadId, messageId) => {
deleteMessageExt(threadId, messageId)
set((state) => ({
messages: {
...state.messages,
[threadId]:
state.messages[threadId]?.filter(
(message) => message.id !== messageId
) || [],
},
}))
},
}),
{
name: localStorageKey.messages,
storage: createJSONStorage(() => localStorage),
} }
) createMessage(newMessage).then((createdMessage) => {
) set((state) => ({
messages: {
...state.messages,
[message.thread_id]: [
...(state.messages[message.thread_id] || []),
createdMessage,
],
},
}))
})
},
deleteMessage: (threadId, messageId) => {
deleteMessageExt(threadId, messageId)
set((state) => ({
messages: {
...state.messages,
[threadId]:
state.messages[threadId]?.filter(
(message) => message.id !== messageId
) || [],
},
}))
},
}))

View File

@ -1,6 +1,4 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { localStorageKey } from '@/constants/localStorage'
import { ulid } from 'ulidx' import { ulid } from 'ulidx'
import { createThread, deleteThread, updateThread } from '@/services/threads' import { createThread, deleteThread, updateThread } from '@/services/threads'
import { Fzf } from 'fzf' import { Fzf } from 'fzf'
@ -30,281 +28,271 @@ type ThreadState = {
searchIndex: Fzf<Thread[]> | null searchIndex: Fzf<Thread[]> | null
} }
export const useThreads = create<ThreadState>()( export const useThreads = create<ThreadState>()((set, get) => ({
persist( threads: {},
(set, get) => ({ searchIndex: null,
threads: {}, setThreads: (threads) => {
searchIndex: null, threads.forEach((thread, index) => {
setThreads: (threads) => { thread.order = index + 1
threads.forEach((thread, index) => { updateThread({
thread.order = index + 1 ...thread,
updateThread({ order: index + 1,
...thread, })
order: index + 1, })
}) const threadMap = threads.reduce(
}) (acc: Record<string, Thread>, thread) => {
const threadMap = threads.reduce( acc[thread.id] = thread
(acc: Record<string, Thread>, thread) => { return acc
acc[thread.id] = thread
return acc
},
{} as Record<string, Thread>
)
set({
threads: threadMap,
searchIndex: new Fzf<Thread[]>(Object.values(threadMap), {
selector: (item: Thread) => item.title,
}),
})
}, },
getFilteredThreads: (searchTerm: string) => { {} as Record<string, Thread>
const { threads, searchIndex } = get() )
set({
threads: threadMap,
searchIndex: new Fzf<Thread[]>(Object.values(threadMap), {
selector: (item: Thread) => item.title,
}),
})
},
getFilteredThreads: (searchTerm: string) => {
const { threads, searchIndex } = get()
// If no search term, return all threads // If no search term, return all threads
if (!searchTerm) { if (!searchTerm) {
// return all threads // return all threads
return Object.values(threads) return Object.values(threads)
}
let currentIndex = searchIndex
if (!currentIndex?.find) {
currentIndex = new Fzf<Thread[]>(Object.values(threads), {
selector: (item: Thread) => item.title,
})
set({ searchIndex: currentIndex })
}
// Use the index to search and return matching threads
const fzfResults = currentIndex.find(searchTerm)
return fzfResults.map(
(result: { item: Thread; positions: Set<number> }) => {
const thread = result.item // Fzf stores the original item here
// Ensure result.positions is an array, default to empty if undefined
const positions = Array.from(result.positions) || []
const highlightedTitle = highlightFzfMatch(thread.title, positions)
return {
...thread,
title: highlightedTitle, // Override title with highlighted version
} }
}
let currentIndex = searchIndex )
if (!currentIndex?.find) { },
currentIndex = new Fzf<Thread[]>(Object.values(threads), { toggleFavorite: (threadId) => {
selector: (item: Thread) => item.title, set((state) => {
}) updateThread({
set({ searchIndex: currentIndex }) ...state.threads[threadId],
} isFavorite: !state.threads[threadId].isFavorite,
})
// Use the index to search and return matching threads return {
const fzfResults = currentIndex.find(searchTerm) threads: {
return fzfResults.map( ...state.threads,
(result: { item: Thread; positions: Set<number> }) => { [threadId]: {
const thread = result.item // Fzf stores the original item here
// Ensure result.positions is an array, default to empty if undefined
const positions = Array.from(result.positions) || []
const highlightedTitle = highlightFzfMatch(thread.title, positions)
return {
...thread,
title: highlightedTitle, // Override title with highlighted version
}
}
)
},
toggleFavorite: (threadId) => {
set((state) => {
updateThread({
...state.threads[threadId], ...state.threads[threadId],
isFavorite: !state.threads[threadId].isFavorite, isFavorite: !state.threads[threadId].isFavorite,
}) },
return { },
threads: { }
...state.threads, })
[threadId]: { },
...state.threads[threadId], deleteThread: (threadId) => {
isFavorite: !state.threads[threadId].isFavorite, set((state) => {
}, // eslint-disable-next-line @typescript-eslint/no-unused-vars
}, const { [threadId]: _, ...remainingThreads } = state.threads
deleteThread(threadId)
return {
threads: remainingThreads,
searchIndex: new Fzf<Thread[]>(Object.values(remainingThreads), {
selector: (item: Thread) => item.title,
}),
}
})
},
deleteAllThreads: () => {
set((state) => {
const allThreadIds = Object.keys(state.threads)
allThreadIds.forEach((threadId) => {
deleteThread(threadId)
})
return {
threads: {},
searchIndex: null, // Or new Fzf([], {selector...})
}
})
},
unstarAllThreads: () => {
set((state) => {
const updatedThreads = Object.keys(state.threads).reduce(
(acc, threadId) => {
acc[threadId] = {
...state.threads[threadId],
isFavorite: false,
} }
}) return acc
}, },
deleteThread: (threadId) => { {} as Record<string, Thread>
set((state) => { )
// eslint-disable-next-line @typescript-eslint/no-unused-vars Object.values(updatedThreads).forEach((thread) => {
const { [threadId]: _, ...remainingThreads } = state.threads updateThread({ ...thread, isFavorite: false })
deleteThread(threadId) })
return { return { threads: updatedThreads }
threads: remainingThreads, })
searchIndex: new Fzf<Thread[]>(Object.values(remainingThreads), { },
selector: (item: Thread) => item.title, getFavoriteThreads: () => {
}), return Object.values(get().threads).filter((thread) => thread.isFavorite)
} },
}) getThreadById: (threadId: string) => {
}, return get().threads[threadId]
deleteAllThreads: () => { },
set((state) => { setCurrentThreadId: (threadId) => {
const allThreadIds = Object.keys(state.threads) set({ currentThreadId: threadId })
allThreadIds.forEach((threadId) => { },
deleteThread(threadId) createThread: async (model, title, assistant) => {
}) const newThread: Thread = {
return { id: ulid(),
threads: {}, title: title ?? 'New Thread',
searchIndex: null, // Or new Fzf([], {selector...}) model,
} // order: 1, // Will be set properly by setThreads
}) updated: Date.now() / 1000,
}, assistants: assistant ? [assistant] : [],
unstarAllThreads: () => {
set((state) => {
const updatedThreads = Object.keys(state.threads).reduce(
(acc, threadId) => {
acc[threadId] = {
...state.threads[threadId],
isFavorite: false,
}
return acc
},
{} as Record<string, Thread>
)
Object.values(updatedThreads).forEach((thread) => {
updateThread({ ...thread, isFavorite: false })
})
return { threads: updatedThreads }
})
},
getFavoriteThreads: () => {
return Object.values(get().threads).filter(
(thread) => thread.isFavorite
)
},
getThreadById: (threadId: string) => {
return get().threads[threadId]
},
setCurrentThreadId: (threadId) => {
set({ currentThreadId: threadId })
},
createThread: async (model, title, assistant) => {
const newThread: Thread = {
id: ulid(),
title: title ?? 'New Thread',
model,
// order: 1, // Will be set properly by setThreads
updated: Date.now() / 1000,
assistants: assistant ? [assistant] : [],
}
return await createThread(newThread).then((createdThread) => {
set((state) => {
// Get all existing threads as an array
const existingThreads = Object.values(state.threads)
// Create new array with the new thread at the beginning
const reorderedThreads = [createdThread, ...existingThreads]
// Use setThreads to handle proper ordering (this will assign order 1, 2, 3...)
get().setThreads(reorderedThreads)
return {
currentThreadId: createdThread.id,
}
})
return createdThread
})
},
updateCurrentThreadAssistant: (assistant) => {
set((state) => {
if (!state.currentThreadId) return { ...state }
const currentThread = state.getCurrentThread()
if (currentThread)
updateThread({
...currentThread,
assistants: [{ ...assistant, model: currentThread.model }],
})
return {
threads: {
...state.threads,
[state.currentThreadId as string]: {
...state.threads[state.currentThreadId as string],
assistants: [assistant],
},
},
}
})
},
updateCurrentThreadModel: (model) => {
set((state) => {
if (!state.currentThreadId) return { ...state }
const currentThread = state.getCurrentThread()
if (currentThread) updateThread({ ...currentThread, model })
return {
threads: {
...state.threads,
[state.currentThreadId as string]: {
...state.threads[state.currentThreadId as string],
model,
},
},
}
})
},
renameThread: (threadId, newTitle) => {
set((state) => {
const thread = state.threads[threadId]
if (!thread) return state
const updatedThread = {
...thread,
title: newTitle,
}
updateThread(updatedThread) // External call, order is fine
const newThreads = { ...state.threads, [threadId]: updatedThread }
return {
threads: newThreads,
searchIndex: new Fzf<Thread[]>(Object.values(newThreads), {
selector: (item: Thread) => item.title,
}),
}
})
},
getCurrentThread: () => {
const { currentThreadId, threads } = get()
return currentThreadId ? threads[currentThreadId] : undefined
},
updateThreadTimestamp: (threadId) => {
set((state) => {
const thread = state.threads[threadId]
if (!thread) return state
// If the thread is already at order 1, just update the timestamp
if (thread.order === 1) {
const updatedThread = {
...thread,
updated: Date.now() / 1000,
}
updateThread(updatedThread)
return {
threads: {
...state.threads,
[threadId]: updatedThread,
},
}
}
// Update the thread with new timestamp and set it to order 1 (top)
const updatedThread = {
...thread,
updated: Date.now() / 1000,
order: 1,
}
// Update all other threads to increment their order by 1
const updatedThreads = { ...state.threads }
Object.keys(updatedThreads).forEach(id => {
if (id !== threadId) {
const otherThread = updatedThreads[id]
updatedThreads[id] = {
...otherThread,
order: (otherThread.order || 1) + 1,
}
// Update the backend for other threads
updateThread(updatedThreads[id])
}
})
// Set the updated thread
updatedThreads[threadId] = updatedThread
// Update the backend for the main thread
updateThread(updatedThread)
return {
threads: updatedThreads,
searchIndex: new Fzf<Thread[]>(Object.values(updatedThreads), {
selector: (item: Thread) => item.title,
}),
}
})
},
}),
{
name: localStorageKey.threads,
storage: createJSONStorage(() => localStorage),
} }
) return await createThread(newThread).then((createdThread) => {
) set((state) => {
// Get all existing threads as an array
const existingThreads = Object.values(state.threads)
// Create new array with the new thread at the beginning
const reorderedThreads = [createdThread, ...existingThreads]
// Use setThreads to handle proper ordering (this will assign order 1, 2, 3...)
get().setThreads(reorderedThreads)
return {
currentThreadId: createdThread.id,
}
})
return createdThread
})
},
updateCurrentThreadAssistant: (assistant) => {
set((state) => {
if (!state.currentThreadId) return { ...state }
const currentThread = state.getCurrentThread()
if (currentThread)
updateThread({
...currentThread,
assistants: [{ ...assistant, model: currentThread.model }],
})
return {
threads: {
...state.threads,
[state.currentThreadId as string]: {
...state.threads[state.currentThreadId as string],
assistants: [assistant],
},
},
}
})
},
updateCurrentThreadModel: (model) => {
set((state) => {
if (!state.currentThreadId) return { ...state }
const currentThread = state.getCurrentThread()
if (currentThread) updateThread({ ...currentThread, model })
return {
threads: {
...state.threads,
[state.currentThreadId as string]: {
...state.threads[state.currentThreadId as string],
model,
},
},
}
})
},
renameThread: (threadId, newTitle) => {
set((state) => {
const thread = state.threads[threadId]
if (!thread) return state
const updatedThread = {
...thread,
title: newTitle,
}
updateThread(updatedThread) // External call, order is fine
const newThreads = { ...state.threads, [threadId]: updatedThread }
return {
threads: newThreads,
searchIndex: new Fzf<Thread[]>(Object.values(newThreads), {
selector: (item: Thread) => item.title,
}),
}
})
},
getCurrentThread: () => {
const { currentThreadId, threads } = get()
return currentThreadId ? threads[currentThreadId] : undefined
},
updateThreadTimestamp: (threadId) => {
set((state) => {
const thread = state.threads[threadId]
if (!thread) return state
// If the thread is already at order 1, just update the timestamp
if (thread.order === 1) {
const updatedThread = {
...thread,
updated: Date.now() / 1000,
}
updateThread(updatedThread)
return {
threads: {
...state.threads,
[threadId]: updatedThread,
},
}
}
// Update the thread with new timestamp and set it to order 1 (top)
const updatedThread = {
...thread,
updated: Date.now() / 1000,
order: 1,
}
// Update all other threads to increment their order by 1
const updatedThreads = { ...state.threads }
Object.keys(updatedThreads).forEach((id) => {
if (id !== threadId) {
const otherThread = updatedThreads[id]
updatedThreads[id] = {
...otherThread,
order: (otherThread.order || 1) + 1,
}
// Update the backend for other threads
updateThread(updatedThreads[id])
}
})
// Set the updated thread
updatedThreads[threadId] = updatedThread
// Update the backend for the main thread
updateThread(updatedThread)
return {
threads: updatedThreads,
searchIndex: new Fzf<Thread[]>(Object.values(updatedThreads), {
selector: (item: Thread) => item.title,
}),
}
})
},
}))