diff --git a/backend/chainlit/data/base.py b/backend/chainlit/data/base.py index 8e17b7bb65..b75c5fd642 100644 --- a/backend/chainlit/data/base.py +++ b/backend/chainlit/data/base.py @@ -113,3 +113,12 @@ async def close(self) -> None: @abstractmethod async def get_favorite_steps(self, user_id: str) -> List["StepDict"]: pass + + async def set_step_favorite( + self, step_dict: "StepDict", favorite: bool + ) -> "StepDict": + metadata = step_dict.get("metadata") or {} + metadata["favorite"] = favorite + step_dict["metadata"] = metadata + await self.update_step(step_dict) + return step_dict diff --git a/backend/chainlit/socket.py b/backend/chainlit/socket.py index dd969e091b..99a3525888 100644 --- a/backend/chainlit/socket.py +++ b/backend/chainlit/socket.py @@ -326,20 +326,45 @@ async def edit_message(sid, payload: MessagePayload): async def message_favorite(sid, payload: MessagePayload): """Handle a message favorite toggle.""" session = WebsocketSession.require(sid) - init_ws_context(session) - messages = chat_context.get() - if config.features.favorites: - for message in messages: - if message.id == payload["message"]["id"]: - if message.metadata is None: - message.metadata = {} - - message.metadata["favorite"] = not message.metadata.get( - "favorite", False - ) - await message.update() - await fetch_favorites(sid) + context = init_ws_context(session) + data_layer = get_data_layer() + + if not config.features.favorites or not session.user: + return + + payload_message = payload["message"] + payload_metadata = payload_message.get("metadata") or {} + favorite = bool(payload_metadata.get("favorite", False)) + + step_dict = None + + if favorite: + for message in chat_context.get(): + if message.id == payload_message["id"]: + message.metadata = message.metadata or {} + message.metadata["favorite"] = favorite + step_dict = message.to_dict() break + elif data_layer: + favorites = await data_layer.get_favorite_steps(session.user.id) + for fav in favorites: + if fav["id"] == payload_message["id"]: + step_dict = fav + break + + if step_dict is None: + logger.error("Could not find step to update favorite status.") + return + + created_at = step_dict.get("createdAt") + if created_at and not created_at.endswith("Z"): + step_dict["createdAt"] = f"{created_at}Z" + + if data_layer: + step_dict = await data_layer.set_step_favorite(step_dict, favorite) + + await context.emitter.update_step(step_dict) + await fetch_favorites(sid) @sio.on("fetch_favorites") # pyright: ignore [reportOptionalCall] diff --git a/backend/chainlit/translations/bn.json b/backend/chainlit/translations/bn.json index 17bc629842..432d53bea2 100644 --- a/backend/chainlit/translations/bn.json +++ b/backend/chainlit/translations/bn.json @@ -73,6 +73,7 @@ "favorites": { "use": "একটি পছন্দের মেসেজ ব্যবহার করুন", "headline": "পছন্দের মেসেজ", + "remove": "পছন্দ বাতিল করুন", "empty": { "title": "এখনও কোনো প্রম্পট সংরক্ষিত নেই", "description": "একটি প্রম্পট পাঠিয়ে এবং তাতে তারকা চিহ্ন দিয়ে শুরু করুন বা আগের চ্যাট থেকে একটি প্রম্পটে তারকা চিহ্ন দিন" diff --git a/backend/chainlit/translations/de-DE.json b/backend/chainlit/translations/de-DE.json index d626294400..235405f901 100644 --- a/backend/chainlit/translations/de-DE.json +++ b/backend/chainlit/translations/de-DE.json @@ -68,6 +68,7 @@ "favorites": { "use": "Eine favorisierte Nachricht verwenden", "headline": "Favorisierte Nachrichten", + "remove": "Favorit entfernen", "empty": { "title": "Noch keine Prompts gespeichert", "description": "Beginne, indem du einen Prompt sendest und mit einem Stern markierst oder markiere einen Prompt aus vorherigen Chats" diff --git a/backend/chainlit/translations/el-GR.json b/backend/chainlit/translations/el-GR.json index bfcbc05883..0e433ccfca 100644 --- a/backend/chainlit/translations/el-GR.json +++ b/backend/chainlit/translations/el-GR.json @@ -68,6 +68,7 @@ "favorites": { "use": "Χρησιμοποιήστε ένα αγαπημένο μήνυμα", "headline": "Αγαπημένα μηνύματα", + "remove": "Αφαίρεση αγαπημένου", "empty": { "title": "Δεν υπάρχουν αποθηκευμένες προτροπές ακόμα", "description": "Ξεκινήστε στέλνοντας μια προτροπή και προσθέστε την στα αγαπημένα ή προσθέστε μια προτροπή από προηγούμενες συνομιλίες" diff --git a/backend/chainlit/translations/en-US.json b/backend/chainlit/translations/en-US.json index 028b750a0a..08684ec9e8 100644 --- a/backend/chainlit/translations/en-US.json +++ b/backend/chainlit/translations/en-US.json @@ -68,6 +68,7 @@ "favorites": { "use": "Use a favorite message", "headline": "Favorite Messages", + "remove": "Remove favorite", "empty": { "title": "No Saved Prompts Yet", "description": "Start by sending a prompt and star it or star a prompt from previous chats" diff --git a/backend/chainlit/translations/es.json b/backend/chainlit/translations/es.json index 0527fe7429..27b5eb9470 100644 --- a/backend/chainlit/translations/es.json +++ b/backend/chainlit/translations/es.json @@ -68,6 +68,7 @@ "favorites": { "use": "Usar un mensaje favorito", "headline": "Mensajes favoritos", + "remove": "Eliminar favorito", "empty": { "title": "Aún no hay prompts guardados", "description": "Comienza enviando un prompt y márcalo con estrella o marca un prompt de chats anteriores" diff --git a/backend/chainlit/translations/fr-FR.json b/backend/chainlit/translations/fr-FR.json index 915ecefc82..1ce1fa3b99 100644 --- a/backend/chainlit/translations/fr-FR.json +++ b/backend/chainlit/translations/fr-FR.json @@ -68,6 +68,7 @@ "favorites": { "use": "Utiliser un message favori", "headline": "Messages favoris", + "remove": "Supprimer des favoris", "empty": { "title": "Aucun prompt enregistré pour le moment", "description": "Commencez par envoyer un prompt et ajoutez-le aux favoris ou ajoutez un prompt de discussions précédentes aux favoris" diff --git a/backend/chainlit/translations/gu.json b/backend/chainlit/translations/gu.json index 905612645a..c253ed4a4d 100644 --- a/backend/chainlit/translations/gu.json +++ b/backend/chainlit/translations/gu.json @@ -73,6 +73,7 @@ "favorites": { "use": "મનપસંદ સંદેશનો ઉપયોગ કરો", "headline": "મનપસંદ સંદેશાઓ", + "remove": "મનપસંદ સંદેશ દૂર કરો", "empty": { "title": "હજી સુધી કોઈ પ્રોમ્પ્ટ સાચવેલ નથી", "description": "એક પ્રોમ્પ્ટ મોકલીને અને તેને સ્ટાર કરીને શરૂઆત કરો અથવા અગાઉની ચેટમાંથી કોઈ પ્રોમ્પ્ટને સ્ટાર કરો" diff --git a/backend/chainlit/translations/he-IL.json b/backend/chainlit/translations/he-IL.json index db787c49e3..de0e75366a 100644 --- a/backend/chainlit/translations/he-IL.json +++ b/backend/chainlit/translations/he-IL.json @@ -73,6 +73,7 @@ "favorites": { "use": "השתמש בהודעה מועדפת", "headline": "הודעות מועדפות", + "remove": "הסר מהמועדפים", "empty": { "title": "עדיין אין הנחיות שמורות", "description": "התחל בשליחת הנחיה וסמן אותה בכוכב או סמן הנחיה משיחות קודמות" diff --git a/backend/chainlit/translations/hi.json b/backend/chainlit/translations/hi.json index 73cff709ee..0407b65ce3 100644 --- a/backend/chainlit/translations/hi.json +++ b/backend/chainlit/translations/hi.json @@ -86,6 +86,7 @@ "favorites": { "use": "पसंदीदा संदेश का उपयोग करें", "headline": "पसंदीदा संदेश", + "remove": "पसंदीदा हटाएं", "empty": { "title": "अभी तक कोई प्रॉम्प्ट सहेजा नहीं गया", "description": "एक प्रॉम्प्ट भेजकर और उसे स्टार करके शुरू करें या पिछली चैट से किसी प्रॉम्प्ट को स्टार करें" diff --git a/backend/chainlit/translations/it.json b/backend/chainlit/translations/it.json index cbc74b9f3a..845b58eaac 100644 --- a/backend/chainlit/translations/it.json +++ b/backend/chainlit/translations/it.json @@ -68,6 +68,7 @@ "favorites": { "use": "Usa un messaggio preferito", "headline": "Messaggi preferiti", + "remove": "Rimuovi preferito", "empty": { "title": "Nessun prompt salvato ancora", "description": "Inizia inviando un prompt e aggiungilo ai preferiti o aggiungi un prompt dalle chat precedenti" diff --git a/backend/chainlit/translations/ja.json b/backend/chainlit/translations/ja.json index ed7ab6f9d4..7b218a8d6a 100644 --- a/backend/chainlit/translations/ja.json +++ b/backend/chainlit/translations/ja.json @@ -73,6 +73,7 @@ "favorites": { "use": "お気に入りのメッセージを使用", "headline": "お気に入りのメッセージ", + "remove": "お気に入りを削除", "empty": { "title": "保存されたプロンプトがまだありません", "description": "プロンプトを送信してスターを付けるか、以前のチャットからプロンプトをスターしてください" diff --git a/backend/chainlit/translations/kn.json b/backend/chainlit/translations/kn.json index 0ad4b05f04..82116892c4 100644 --- a/backend/chainlit/translations/kn.json +++ b/backend/chainlit/translations/kn.json @@ -68,6 +68,7 @@ "favorites": { "use": "ಮೆಚ್ಚಿನ ಸಂದೇಶವನ್ನು ಬಳಸಿ", "headline": "ಮೆಚ್ಚಿನ ಸಂದೇಶಗಳು", + "remove": "ಮೆಚ್ಚಿನ ಸಂದೇಶವನ್ನು ತೆಗೆದುಹಾಕಿ", "empty": { "title": "ಇನ್ನೂ ಯಾವುದೇ ಪ್ರಾಂಪ್ಟ್‌ಗಳನ್ನು ಉಳಿಸಲಾಗಿಲ್ಲ", "description": "ಪ್ರಾಂಪ್ಟ್ ಕಳುಹಿಸಿ ಮತ್ತು ಅದಕ್ಕೆ ಸ್ಟಾರ್ ಮಾಡಿ ಅಥವಾ ಹಿಂದಿನ ಚಾಟ್‌ಗಳಿಂದ ಪ್ರಾಂಪ್ಟ್‌ಗೆ ಸ್ಟಾರ್ ಮಾಡಿ" diff --git a/backend/chainlit/translations/ko.json b/backend/chainlit/translations/ko.json index 2d286036a5..bd299591f1 100644 --- a/backend/chainlit/translations/ko.json +++ b/backend/chainlit/translations/ko.json @@ -68,6 +68,7 @@ "favorites": { "use": "즐겨찾기 메시지 사용", "headline": "즐겨찾기 메시지", + "remove": "즐겨찾기 제거", "empty": { "title": "저장된 프롬프트가 아직 없습니다", "description": "프롬프트를 보내고 별표를 추가하거나 이전 대화에서 프롬프트에 별표를 추가하세요" diff --git a/backend/chainlit/translations/ml.json b/backend/chainlit/translations/ml.json index 70eb7ea1f8..2469042d0e 100644 --- a/backend/chainlit/translations/ml.json +++ b/backend/chainlit/translations/ml.json @@ -68,6 +68,7 @@ "favorites": { "use": "പ്രിയപ്പെട്ട സന്ദേശം ഉപയോഗിക്കുക", "headline": "പ്രിയപ്പെട്ട സന്ദേശങ്ങൾ", + "remove": "ഇഷ്ടപ്പെട്ടത് നീക്കം ചെയ്യുക", "empty": { "title": "ഇതുവരെ സംരക്ഷിച്ച പ്രോംപ്റ്റുകളൊന്നുമില്ല", "description": "ഒരു പ്രോംപ്റ്റ് അയച്ച് അതിന് സ്റ്റാർ ചെയ്തുകൊണ്ട് ആരംഭിക്കുക അല്ലെങ്കിൽ മുൻ ചാറ്റുകളിൽ നിന്ന് ഒരു പ്രോംപ്റ്റിന് സ്റ്റാർ ചെയ്യുക" diff --git a/backend/chainlit/translations/mr.json b/backend/chainlit/translations/mr.json index 2d232c4a71..159cb8074a 100644 --- a/backend/chainlit/translations/mr.json +++ b/backend/chainlit/translations/mr.json @@ -73,6 +73,7 @@ "favorites": { "use": "आवडता संदेश वापरा", "headline": "आवडते संदेश", + "remove": "आवडता संदेश काढा", "empty": { "title": "अद्याप कोणतेही प्रॉम्प्ट जतन केलेले नाहीत", "description": "एक प्रॉम्प्ट पाठवून आणि त्यावर स्टार करून सुरुवात करा किंवा मागील चॅटमधून प्रॉम्प्टवर स्टार करा" diff --git a/backend/chainlit/translations/nl.json b/backend/chainlit/translations/nl.json index 43a5e522ba..6e177f484b 100644 --- a/backend/chainlit/translations/nl.json +++ b/backend/chainlit/translations/nl.json @@ -86,6 +86,7 @@ "favorites": { "use": "Gebruik een favoriet bericht", "headline": "Favoriete berichten", + "remove": "Verwijder favoriet", "empty": { "title": "Nog geen opgeslagen prompts", "description": "Begin door een prompt te versturen en voeg deze toe aan favorieten of voeg een prompt uit eerdere chats toe" diff --git a/backend/chainlit/translations/ta.json b/backend/chainlit/translations/ta.json index 9324ff3479..be051916f7 100644 --- a/backend/chainlit/translations/ta.json +++ b/backend/chainlit/translations/ta.json @@ -68,6 +68,7 @@ "favorites": { "use": "விருப்பமான செய்தியைப் பயன்படுத்தவும்", "headline": "விருப்பமான செய்திகள்", + "remove": "பிடித்ததை நீக்கு", "empty": { "title": "இன்னும் சேமிக்கப்பட்ட ப்ராம்ப்ட்கள் இல்லை", "description": "ஒரு ப்ராம்ப்ட் அனுப்பி அதை ஸ்டார் செய்வதன் மூலம் தொடங்கவும் அல்லது முந்தைய அரட்டைகளில் இருந்து ஒரு ப்ராம்ப்ட்டை ஸ்டார் செய்யவும்" diff --git a/backend/chainlit/translations/te.json b/backend/chainlit/translations/te.json index 8eb7d40db3..e84865725a 100644 --- a/backend/chainlit/translations/te.json +++ b/backend/chainlit/translations/te.json @@ -73,6 +73,7 @@ "favorites": { "use": "ఇష్టమైన సందేశాన్ని ఉపయోగించండి", "headline": "ఇష్టమైన సందేశాలు", + "remove": "ఇష్టమైనదాన్ని తొలగించండి", "empty": { "title": "ఇంకా ప్రాంప్ట్‌లు సేవ్ చేయలేదు", "description": "ఒక ప్రాంప్ట్ పంపి దానికి స్టార్ చేయడం ద్వారా ప్రారంభించండి లేదా మునుపటి చాట్‌ల నుండి ప్రాంప్ట్‌కు స్టార్ చేయండి" diff --git a/backend/chainlit/translations/zh-CN.json b/backend/chainlit/translations/zh-CN.json index 5a6348f1f7..3fdbd348fa 100644 --- a/backend/chainlit/translations/zh-CN.json +++ b/backend/chainlit/translations/zh-CN.json @@ -86,6 +86,7 @@ "favorites": { "use": "使用收藏的消息", "headline": "收藏的消息", + "remove": "移除收藏", "empty": { "title": "尚未保存的提示", "description": "从发送提示并加星标开始,或从之前的聊天中加星标提示" diff --git a/backend/chainlit/translations/zh-TW.json b/backend/chainlit/translations/zh-TW.json index 5e2e1421aa..ea541cbebf 100644 --- a/backend/chainlit/translations/zh-TW.json +++ b/backend/chainlit/translations/zh-TW.json @@ -86,6 +86,7 @@ "favorites": { "use": "使用收藏的訊息", "headline": "收藏的訊息", + "remove": "移除收藏", "empty": { "title": "尚未儲存的提示", "description": "從發送提示並加星號開始,或從之前的聊天中加星號提示" diff --git a/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx b/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx index b3d322cafb..6e80a29617 100644 --- a/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx +++ b/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx @@ -4,11 +4,15 @@ import { PopoverContent, PopoverTrigger } from '@radix-ui/react-popover'; -import { Star } from 'lucide-react'; +import { Star, Trash } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { useRecoilValue } from 'recoil'; -import { favoriteMessagesState, useConfig } from '@chainlit/react-client'; +import { + favoriteMessagesState, + useChatInteract, + useConfig +} from '@chainlit/react-client'; import { useTranslation } from '@/components/i18n/Translator'; import { Button } from '@/components/ui/button'; @@ -35,6 +39,7 @@ interface Props { export const FavoriteButton = ({ disabled = false, onSelect }: Props) => { const favorites = useRecoilValue(favoriteMessagesState); + const { toggleMessageFavorite } = useChatInteract(); const { config } = useConfig(); const { t } = useTranslation(); @@ -135,18 +140,38 @@ export const FavoriteButton = ({ disabled = false, onSelect }: Props) => { {favorites.map((step) => ( { onSelect(step.output); setOpen(false); cancelTooltipOpen(); }} - className="cursor-pointer" + className="cursor-pointer group" > -
- {step.output} - - {new Date(step.createdAt).toLocaleDateString()} - +
+
+ + {step.output} + + + {new Date(step.createdAt).toLocaleDateString()} + +
+
))} diff --git a/frontend/tests/FavoriteButton.spec.tsx b/frontend/tests/FavoriteButton.spec.tsx index c35587f9c1..c84c846e8e 100644 --- a/frontend/tests/FavoriteButton.spec.tsx +++ b/frontend/tests/FavoriteButton.spec.tsx @@ -10,6 +10,8 @@ import { import { FavoriteButton } from '@/components/chat/MessageComposer/FavoriteButton'; +const toggleMessageFavoriteMock = vi.fn(); + vi.mock('@/components/i18n/Translator', () => ({ useTranslation: () => ({ t: (key: string) => { @@ -17,7 +19,9 @@ vi.mock('@/components/i18n/Translator', () => ({ 'chat.favorites.use': 'Use favorite', 'chat.favorites.headline': 'Favorites List', 'chat.favorites.empty.title': 'No Saved Prompts Yet', - 'chat.favorites.empty.description': 'Start by sending a prompt and star it or star a prompt from previous chats' + 'chat.favorites.empty.description': + 'Start by sending a prompt and star it or star a prompt from previous chats', + 'chat.favorites.remove': 'Remove favorite' }; return trans[key] || key; } @@ -28,6 +32,9 @@ vi.mock('@chainlit/react-client', async () => { const { atom } = await import('recoil'); return { useConfig: vi.fn(), + useChatInteract: () => ({ + toggleMessageFavorite: toggleMessageFavoriteMock + }), favoriteMessagesState: atom({ key: 'favoriteMessagesState', default: [] @@ -108,7 +115,11 @@ describe('FavoriteButton', () => { // Verify empty state message appears expect(screen.getByText('No Saved Prompts Yet')).toBeInTheDocument(); - expect(screen.getByText('Start by sending a prompt and star it or star a prompt from previous chats')).toBeInTheDocument(); + expect( + screen.getByText( + 'Start by sending a prompt and star it or star a prompt from previous chats' + ) + ).toBeInTheDocument(); }); it('shows empty state message when popover is opened with no favorites', () => { @@ -123,7 +134,9 @@ describe('FavoriteButton', () => { // Empty state should be visible const emptyTitle = screen.getByText('No Saved Prompts Yet'); - const emptyDescription = screen.getByText('Start by sending a prompt and star it or star a prompt from previous chats'); + const emptyDescription = screen.getByText( + 'Start by sending a prompt and star it or star a prompt from previous chats' + ); expect(emptyTitle).toBeInTheDocument(); expect(emptyDescription).toBeInTheDocument(); @@ -179,6 +192,55 @@ describe('FavoriteButton', () => { expect(mockOnSelect).toHaveBeenCalledWith('How do I center a div?'); }); + it('renders favorites with duplicate text independently', () => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: true } } + }); + + const duplicates: IStep[] = [ + { + id: 'msg_dup_1', + output: 'Same prompt', + createdAt: new Date('2023-10-02').getTime(), + type: 'assistant_message', + name: 'Assistant' + }, + { + id: 'msg_dup_2', + output: 'Same prompt', + createdAt: new Date('2023-10-03').getTime(), + type: 'assistant_message', + name: 'Assistant' + } + ]; + + renderComponent(duplicates); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const duplicateEntries = screen.getAllByText('Same prompt'); + expect(duplicateEntries).toHaveLength(2); + }); + + it('removes a favorite without selecting the item', () => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: true } } + }); + + renderComponent(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const removeButtons = screen.getAllByLabelText('Remove favorite'); + fireEvent.click(removeButtons[0]); + + expect(toggleMessageFavoriteMock).toHaveBeenCalledTimes(1); + expect(toggleMessageFavoriteMock).toHaveBeenCalledWith(mockFavorites[0]); + expect(mockOnSelect).not.toHaveBeenCalled(); + }); + it('respects the disabled prop', () => { (useConfig as any).mockReturnValue({ config: { features: { favorites: true } } diff --git a/libs/react-client/src/useChatInteract.ts b/libs/react-client/src/useChatInteract.ts index c80eb2aa40..d02e381455 100644 --- a/libs/react-client/src/useChatInteract.ts +++ b/libs/react-client/src/useChatInteract.ts @@ -7,6 +7,7 @@ import { chatSettingsValueState, currentThreadIdState, elementState, + favoriteMessagesState, firstUserInteraction, loadingState, messagesState, @@ -45,6 +46,7 @@ const useChatInteract = () => { const setIdToResume = useSetRecoilState(threadIdToResumeState); const setSideView = useSetRecoilState(sideViewState); const setCurrentThreadId = useSetRecoilState(currentThreadIdState); + const setFavoriteMessages = useSetRecoilState(favoriteMessagesState); const clear = useCallback(() => { session?.socket.emit('clear_session'); @@ -90,9 +92,32 @@ const useChatInteract = () => { const toggleMessageFavorite = useCallback( (message: IStep) => { - session?.socket.emit('message_favorite', { message }); + const favorite = !(message.metadata?.favorite ?? false); + const nextMessage: IStep = { + ...message, + metadata: { + ...(message.metadata || {}), + favorite + } + }; + + setMessages((oldMessages) => + oldMessages.map((item) => (item.id === message.id ? nextMessage : item)) + ); + + setFavoriteMessages((oldFavorites) => { + if (favorite) { + const filtered = oldFavorites.filter( + (step) => step.id !== message.id + ); + return [nextMessage, ...filtered]; + } + return oldFavorites.filter((step) => step.id !== message.id); + }); + + session?.socket.emit('message_favorite', { message: nextMessage }); }, - [session?.socket] + [session?.socket, setFavoriteMessages, setMessages] ); const windowMessage = useCallback(