From 548c7cd12b1b3f551050347a6373a9c82b39ddf4 Mon Sep 17 00:00:00 2001 From: Michael Eisele Date: Fri, 19 Dec 2025 12:12:35 +0100 Subject: [PATCH 1/9] feat: add translations for favorites --- backend/chainlit/translations/bn.json | 4 ++++ backend/chainlit/translations/de-DE.json | 4 ++++ backend/chainlit/translations/el-GR.json | 4 ++++ backend/chainlit/translations/en-US.json | 4 ++++ backend/chainlit/translations/es.json | 4 ++++ backend/chainlit/translations/fr-FR.json | 4 ++++ backend/chainlit/translations/gu.json | 4 ++++ backend/chainlit/translations/he-IL.json | 4 ++++ backend/chainlit/translations/hi.json | 4 ++++ backend/chainlit/translations/it.json | 4 ++++ backend/chainlit/translations/ja.json | 4 ++++ backend/chainlit/translations/kn.json | 4 ++++ backend/chainlit/translations/ko.json | 4 ++++ backend/chainlit/translations/ml.json | 4 ++++ backend/chainlit/translations/mr.json | 4 ++++ backend/chainlit/translations/nl.json | 4 ++++ backend/chainlit/translations/ta.json | 4 ++++ backend/chainlit/translations/te.json | 4 ++++ backend/chainlit/translations/zh-CN.json | 4 ++++ backend/chainlit/translations/zh-TW.json | 4 ++++ 20 files changed, 80 insertions(+) diff --git a/backend/chainlit/translations/bn.json b/backend/chainlit/translations/bn.json index 8ec8b15ab2..359e9b461b 100644 --- a/backend/chainlit/translations/bn.json +++ b/backend/chainlit/translations/bn.json @@ -70,6 +70,10 @@ "stop": "রেকর্ডিং বন্ধ করুন", "connecting": "সংযোগ করা হচ্ছে" }, + "favorites": { + "use": "একটি পছন্দের মেসেজ ব্যবহার করুন", + "headline": "পছন্দের মেসেজ" + }, "commands": { "button": "টুলস", "changeTool": "টুল পরিবর্তন করুন", diff --git a/backend/chainlit/translations/de-DE.json b/backend/chainlit/translations/de-DE.json index d31ed9d457..ff1bd624a0 100644 --- a/backend/chainlit/translations/de-DE.json +++ b/backend/chainlit/translations/de-DE.json @@ -65,6 +65,10 @@ "attachFiles": "Dateien anhängen" } }, + "favorites": { + "use": "Eine favorisierte Nachricht verwenden", + "headline": "Favorisierte Nachrichten" + }, "commands": { "button": "Tools", "changeTool": "Tool wechseln", diff --git a/backend/chainlit/translations/el-GR.json b/backend/chainlit/translations/el-GR.json index adaa89d830..e5114f85d1 100644 --- a/backend/chainlit/translations/el-GR.json +++ b/backend/chainlit/translations/el-GR.json @@ -65,6 +65,10 @@ "attachFiles": "Επισύναψη αρχείων" } }, + "favorites": { + "use": "Χρησιμοποιήστε ένα αγαπημένο μήνυμα", + "headline": "Αγαπημένα μηνύματα" + }, "commands": { "button": "Εργαλεία", "changeTool": "Αλλαγή Εργαλείου", diff --git a/backend/chainlit/translations/en-US.json b/backend/chainlit/translations/en-US.json index c6a6af0914..5a784744e8 100644 --- a/backend/chainlit/translations/en-US.json +++ b/backend/chainlit/translations/en-US.json @@ -65,6 +65,10 @@ "attachFiles": "Attach files" } }, + "favorites": { + "use": "Use a favorite message", + "headline": "Favorite Messages" + }, "commands": { "button": "Tools", "changeTool": "Change Tool", diff --git a/backend/chainlit/translations/es.json b/backend/chainlit/translations/es.json index 60ba1390c0..be0db47932 100644 --- a/backend/chainlit/translations/es.json +++ b/backend/chainlit/translations/es.json @@ -65,6 +65,10 @@ "attachFiles": "Adjuntar archivos" } }, + "favorites": { + "use": "Usar un mensaje favorito", + "headline": "Mensajes favoritos" + }, "commands": { "button": "Herramientas", "changeTool": "Cambiar herramienta", diff --git a/backend/chainlit/translations/fr-FR.json b/backend/chainlit/translations/fr-FR.json index 39c1c6f721..8289c37e89 100644 --- a/backend/chainlit/translations/fr-FR.json +++ b/backend/chainlit/translations/fr-FR.json @@ -65,6 +65,10 @@ "attachFiles": "Joindre des fichiers" } }, + "favorites": { + "use": "Utiliser un message favori", + "headline": "Messages favoris" + }, "commands": { "button": "Outils", "changeTool": "Changer d'outil", diff --git a/backend/chainlit/translations/gu.json b/backend/chainlit/translations/gu.json index efce7c1bea..2f6e3683c4 100644 --- a/backend/chainlit/translations/gu.json +++ b/backend/chainlit/translations/gu.json @@ -70,6 +70,10 @@ "stop": "રેકોર્ડિંગ બંધ કરો", "connecting": "કનેક્ટ થઈ રહ્યું છે" }, + "favorites": { + "use": "મનપસંદ સંદેશનો ઉપયોગ કરો", + "headline": "મનપસંદ સંદેશાઓ" + }, "commands": { "button": "ટૂલ્સ", "changeTool": "ટૂલ બદલો", diff --git a/backend/chainlit/translations/he-IL.json b/backend/chainlit/translations/he-IL.json index 5ff8460dbe..e39fbcad52 100644 --- a/backend/chainlit/translations/he-IL.json +++ b/backend/chainlit/translations/he-IL.json @@ -70,6 +70,10 @@ "stop": "עצור הקלטה", "connecting": "מתחבר" }, + "favorites": { + "use": "השתמש בהודעה מועדפת", + "headline": "הודעות מועדפות" + }, "commands": { "button": "כלים", "changeTool": "שנה כלי", diff --git a/backend/chainlit/translations/hi.json b/backend/chainlit/translations/hi.json index eb3dbc7ba0..f4adc05760 100644 --- a/backend/chainlit/translations/hi.json +++ b/backend/chainlit/translations/hi.json @@ -83,6 +83,10 @@ "removeAttachment": "संलग्नक हटाएं" } }, + "favorites": { + "use": "पसंदीदा संदेश का उपयोग करें", + "headline": "पसंदीदा संदेश" + }, "commands": { "button": "उपकरण", "changeTool": "उपकरण बदलें", diff --git a/backend/chainlit/translations/it.json b/backend/chainlit/translations/it.json index 36cf8c7bf4..7651368809 100644 --- a/backend/chainlit/translations/it.json +++ b/backend/chainlit/translations/it.json @@ -65,6 +65,10 @@ "attachFiles": "Allega file" } }, + "favorites": { + "use": "Usa un messaggio preferito", + "headline": "Messaggi preferiti" + }, "commands": { "button": "Strumenti", "changeTool": "Cambia strumento", diff --git a/backend/chainlit/translations/ja.json b/backend/chainlit/translations/ja.json index c58932d896..432c09ff83 100644 --- a/backend/chainlit/translations/ja.json +++ b/backend/chainlit/translations/ja.json @@ -70,6 +70,10 @@ "stop": "録音停止", "connecting": "接続中" }, + "favorites": { + "use": "お気に入りのメッセージを使用", + "headline": "お気に入りのメッセージ" + }, "commands": { "button": "ツール", "changeTool": "ツールを変更", diff --git a/backend/chainlit/translations/kn.json b/backend/chainlit/translations/kn.json index 576a238c74..9d66253452 100644 --- a/backend/chainlit/translations/kn.json +++ b/backend/chainlit/translations/kn.json @@ -65,6 +65,10 @@ "attachFiles": "ಫೈಲ್‌ಗಳನ್ನು ಲಗತ್ತಿಸಿ" } }, + "favorites": { + "use": "ಮೆಚ್ಚಿನ ಸಂದೇಶವನ್ನು ಬಳಸಿ", + "headline": "ಮೆಚ್ಚಿನ ಸಂದೇಶಗಳು" + }, "commands": { "button": "ಉಪಕರಣಗಳು", "changeTool": "ಉಪಕರಣವನ್ನು ಬದಲಿಸಿ", diff --git a/backend/chainlit/translations/ko.json b/backend/chainlit/translations/ko.json index 5f4e3463c0..215f2700b5 100644 --- a/backend/chainlit/translations/ko.json +++ b/backend/chainlit/translations/ko.json @@ -65,6 +65,10 @@ "attachFiles": "파일 첨부" } }, + "favorites": { + "use": "즐겨찾기 메시지 사용", + "headline": "즐겨찾기 메시지" + }, "commands": { "button": "도구", "changeTool": "도구 변경", diff --git a/backend/chainlit/translations/ml.json b/backend/chainlit/translations/ml.json index 70ace53c10..328dceae1f 100644 --- a/backend/chainlit/translations/ml.json +++ b/backend/chainlit/translations/ml.json @@ -65,6 +65,10 @@ "attachFiles": "ഫയലുകൾ അറ്റാച്ച് ചെയ്യുക" } }, + "favorites": { + "use": "പ്രിയപ്പെട്ട സന്ദേശം ഉപയോഗിക്കുക", + "headline": "പ്രിയപ്പെട്ട സന്ദേശങ്ങൾ" + }, "commands": { "button": "ഉപകരണങ്ങൾ", "changeTool": "ഉപകരണം മാറ്റുക", diff --git a/backend/chainlit/translations/mr.json b/backend/chainlit/translations/mr.json index 7bec9b2fc7..ea856674c8 100644 --- a/backend/chainlit/translations/mr.json +++ b/backend/chainlit/translations/mr.json @@ -70,6 +70,10 @@ "stop": "रेकॉर्डिंग थांबवा", "connecting": "कनेक्ट करत आहे" }, + "favorites": { + "use": "आवडता संदेश वापरा", + "headline": "आवडते संदेश" + }, "commands": { "button": "साधने", "changeTool": "साधन बदला", diff --git a/backend/chainlit/translations/nl.json b/backend/chainlit/translations/nl.json index 174d1d11a2..4d2032d3b0 100644 --- a/backend/chainlit/translations/nl.json +++ b/backend/chainlit/translations/nl.json @@ -83,6 +83,10 @@ "removeAttachment": "Verwijder bijlage" } }, + "favorites": { + "use": "Gebruik een favoriet bericht", + "headline": "Favoriete berichten" + }, "commands": { "button": "Hulpmiddelen", "changeTool": "Wijzig hulpmiddel", diff --git a/backend/chainlit/translations/ta.json b/backend/chainlit/translations/ta.json index 4935ed0aad..75f6d6e89b 100644 --- a/backend/chainlit/translations/ta.json +++ b/backend/chainlit/translations/ta.json @@ -65,6 +65,10 @@ "attachFiles": "கோப்புகளை இணை" } }, + "favorites": { + "use": "விருப்பமான செய்தியைப் பயன்படுத்தவும்", + "headline": "விருப்பமான செய்திகள்" + }, "commands": { "button": "கருவிகள்", "changeTool": "கருவியை மாற்றவும்", diff --git a/backend/chainlit/translations/te.json b/backend/chainlit/translations/te.json index fdae2b7a2c..aa07a64a6a 100644 --- a/backend/chainlit/translations/te.json +++ b/backend/chainlit/translations/te.json @@ -70,6 +70,10 @@ "stop": "రికార్డింగ్ ఆపండి", "connecting": "అనుసంధానిస్తోంది" }, + "favorites": { + "use": "ఇష్టమైన సందేశాన్ని ఉపయోగించండి", + "headline": "ఇష్టమైన సందేశాలు" + }, "commands": { "button": "పరికరాలు", "changeTool": "పరికరాన్ని మార్చండి", diff --git a/backend/chainlit/translations/zh-CN.json b/backend/chainlit/translations/zh-CN.json index 7c034c1e71..08027cdf76 100644 --- a/backend/chainlit/translations/zh-CN.json +++ b/backend/chainlit/translations/zh-CN.json @@ -83,6 +83,10 @@ "removeAttachment": "移除附件" } }, + "favorites": { + "use": "使用收藏的消息", + "headline": "收藏的消息" + }, "commands": { "button": "工具", "changeTool": "更换工具", diff --git a/backend/chainlit/translations/zh-TW.json b/backend/chainlit/translations/zh-TW.json index 56b8d8aa33..b0ce76514b 100644 --- a/backend/chainlit/translations/zh-TW.json +++ b/backend/chainlit/translations/zh-TW.json @@ -83,6 +83,10 @@ "removeAttachment": "移除附件" } }, + "favorites": { + "use": "使用收藏的訊息", + "headline": "收藏的訊息" + }, "commands": { "button": "工具", "changeTool": "更換工具", From 2450122b637245abf3d8493bfdc92334482954c8 Mon Sep 17 00:00:00 2001 From: Michael Eisele Date: Fri, 19 Dec 2025 12:14:36 +0100 Subject: [PATCH 2/9] feat: adjust react client to fetch favorites and support favorites config --- libs/react-client/src/state.ts | 5 +++++ libs/react-client/src/types/config.ts | 1 + libs/react-client/src/useChatInteract.ts | 10 +++++++++- libs/react-client/src/useChatSession.ts | 11 ++++++++++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/libs/react-client/src/state.ts b/libs/react-client/src/state.ts index 3cf06df3db..fa9b422365 100644 --- a/libs/react-client/src/state.ts +++ b/libs/react-client/src/state.ts @@ -271,3 +271,8 @@ export const mcpState = atom({ default: [], effects: [localStorageEffect('mcp_storage_key')] }); + +export const favoriteMessagesState = atom({ + key: 'favoriteMessagesState', + default: [] +}); diff --git a/libs/react-client/src/types/config.ts b/libs/react-client/src/types/config.ts index 322339f59b..e95b75fa9a 100644 --- a/libs/react-client/src/types/config.ts +++ b/libs/react-client/src/types/config.ts @@ -69,6 +69,7 @@ export interface IChainlitConfig { assistant_message_autoscroll?: boolean; latex?: boolean; edit_message?: boolean; + favorites?: boolean; mcp?: { enabled?: boolean; sse?: { diff --git a/libs/react-client/src/useChatInteract.ts b/libs/react-client/src/useChatInteract.ts index e18639da0e..c80eb2aa40 100644 --- a/libs/react-client/src/useChatInteract.ts +++ b/libs/react-client/src/useChatInteract.ts @@ -88,6 +88,13 @@ const useChatInteract = () => { [session?.socket] ); + const toggleMessageFavorite = useCallback( + (message: IStep) => { + session?.socket.emit('message_favorite', { message }); + }, + [session?.socket] + ); + const windowMessage = useCallback( (data: any) => { session?.socket.emit('window_message', data); @@ -170,7 +177,8 @@ const useChatInteract = () => { endAudioStream, stopTask, setIdToResume, - updateChatSettings + updateChatSettings, + toggleMessageFavorite }; }; diff --git a/libs/react-client/src/useChatSession.ts b/libs/react-client/src/useChatSession.ts index 535ba35192..4be25c9141 100644 --- a/libs/react-client/src/useChatSession.ts +++ b/libs/react-client/src/useChatSession.ts @@ -19,6 +19,7 @@ import { commandsState, currentThreadIdState, elementState, + favoriteMessagesState, firstUserInteraction, isAiSpeakingState, loadingState, @@ -85,6 +86,7 @@ const useChatSession = () => { const [chatProfile, setChatProfile] = useRecoilState(chatProfileState); const idToResume = useRecoilValue(threadIdToResumeState); const setThreadResumeError = useSetRecoilState(resumeThreadErrorState); + const setFavoriteMessages = useSetRecoilState(favoriteMessagesState); const [currentThreadId, setCurrentThreadId] = useRecoilState(currentThreadIdState); @@ -140,6 +142,7 @@ const useChatSession = () => { socket.on('connect', () => { socket.emit('connection_successful'); setSession((s) => ({ ...s!, error: false })); + socket.emit('fetch_favorites'); setMcps((prev) => prev.map((mcp) => { let promise; @@ -245,7 +248,9 @@ const useChatSession = () => { }); socket.on('resume_thread', (thread: IThread) => { - const isReadOnlyView = Boolean((thread as any)?.metadata?.viewer_read_only); + const isReadOnlyView = Boolean( + (thread as any)?.metadata?.viewer_read_only + ); if (!isReadOnlyView && idToResume && thread.id !== idToResume) { window.location.href = `/thread/${thread.id}`; } @@ -362,6 +367,10 @@ const useChatSession = () => { setModes(modes); }); + socket.on('set_favorites', (steps: IStep[]) => { + setFavoriteMessages(steps); + }); + socket.on('set_sidebar_title', (title: string) => { setSideView((prev) => { if (prev?.title === title) return prev; From 8cebef5b4dd920e71314ccbff0a0d039dd7389f8 Mon Sep 17 00:00:00 2001 From: Michael Eisele Date: Fri, 19 Dec 2025 12:16:41 +0100 Subject: [PATCH 3/9] feat: add frontend display for favorite messages --- .../chat/MessageComposer/FavoriteButton.tsx | 103 ++++++++++++++++++ .../components/chat/MessageComposer/index.tsx | 10 ++ .../chat/Messages/Message/UserMessage.tsx | 24 +++- 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/chat/MessageComposer/FavoriteButton.tsx diff --git a/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx b/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx new file mode 100644 index 0000000000..2e5de17c83 --- /dev/null +++ b/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx @@ -0,0 +1,103 @@ +import { cn } from '@/lib/utils'; +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@radix-ui/react-popover'; +import { Star } from 'lucide-react'; +import { useState } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { favoriteMessagesState, useConfig } from '@chainlit/react-client'; + +import { useTranslation } from '@/components/i18n/Translator'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandGroup, + CommandItem, + CommandListScrollable +} from '@/components/ui/command'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from '@/components/ui/tooltip'; + +interface Props { + disabled?: boolean; + onSelect: (content: string) => void; +} + +export const FavoriteButton = ({ disabled = false, onSelect }: Props) => { + const favorites = useRecoilValue(favoriteMessagesState); + const { config } = useConfig(); + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + if (!config?.features?.favorites) return null; + if (!favorites.length) return null; + + return ( +
+ + + + + + + + + +

{t('chat.favorites.use')}

+
+
+
+ + + + + + {favorites.map((step) => ( + { + onSelect(step.output); + setOpen(false); + }} + className="cursor-pointer" + > +
+ {step.output} + + {new Date(step.createdAt).toLocaleDateString()} + +
+
+ ))} +
+
+
+
+
+
+ ); +}; + +export default FavoriteButton; diff --git a/frontend/src/components/chat/MessageComposer/index.tsx b/frontend/src/components/chat/MessageComposer/index.tsx index d3bdd42994..8434c1b76f 100644 --- a/frontend/src/components/chat/MessageComposer/index.tsx +++ b/frontend/src/components/chat/MessageComposer/index.tsx @@ -35,6 +35,7 @@ import { import { Attachments } from './Attachments'; import CommandButtons from './CommandButtons'; import CommandButton from './CommandPopoverButton'; +import FavoriteButton from './FavoriteButton'; import Input, { InputMethods } from './Input'; import McpButton from './Mcp'; import ModePicker from './ModePicker'; @@ -109,6 +110,13 @@ export default function MessageComposer({ const [promptUsed, setPromptUsed] = useState(false); + const onFavoriteSelect = useCallback((content: string) => { + setValue(content); + if (inputRef.current) { + inputRef.current.setValueExtern(content); + } + }, []); + const onPaste = useCallback( (event: ClipboardEvent) => { if (event.clipboardData && event.clipboardData.items) { @@ -291,6 +299,8 @@ export default function MessageComposer({ selectedCommandId={selectedCommand?.id} onCommandSelect={setSelectedCommand} /> + +
) { const { askUser, loading, editable } = useContext(MessageContext); - const { editMessage } = useChatInteract(); + const { editMessage, toggleMessageFavorite } = useChatInteract(); + const { config } = useConfig(); const setMessages = useSetRecoilState(messagesState); const disabled = loading || !!askUser; + const isFavorite = message.metadata?.favorite === true; const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(''); @@ -39,6 +43,7 @@ const UserMessage = memo(function UserMessage({ (el) => el.forId === message.id && el.display === 'inline' ); }, [message.id, elements]); + const favoritesEnabled = !!config?.features?.favorites; const handleEdit = () => { if (editValue) { @@ -75,6 +80,21 @@ const UserMessage = memo(function UserMessage({ )} + {!isEditing && favoritesEnabled && ( + + )}
Date: Mon, 22 Dec 2025 10:49:17 +0100 Subject: [PATCH 4/9] feat: add socket methods for fetching favorite messages --- backend/chainlit/config.py | 4 + backend/chainlit/data/base.py | 4 + backend/chainlit/data/chainlit_data_layer.py | 14 +- backend/chainlit/data/dynamodb.py | 65 ++++++- backend/chainlit/data/literalai.py | 4 + backend/chainlit/data/sql_alchemy.py | 74 ++++++++ backend/chainlit/emitter.py | 11 ++ backend/chainlit/socket.py | 30 ++++ frontend/tests/FeedbackButton.spec.tsx | 173 +++++++++++++++++++ 9 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 frontend/tests/FeedbackButton.spec.tsx diff --git a/backend/chainlit/config.py b/backend/chainlit/config.py index 9f0b9e8179..a7bc9c30bc 100644 --- a/backend/chainlit/config.py +++ b/backend/chainlit/config.py @@ -111,6 +111,9 @@ # Allow users to share threads (backend + UI). Requires an app-defined on_shared_thread_view callback. allow_thread_sharing = false +# Enable favorite messages +favorites = false + [features.slack] # Add emoji reaction when message is received (requires reactions:write OAuth scope) reaction_on_message_received = false @@ -316,6 +319,7 @@ class FeaturesSettings(BaseModel): auto_tag_thread: bool = True edit_message: bool = True allow_thread_sharing: bool = False + favorites: bool = False class HeaderLink(BaseModel): diff --git a/backend/chainlit/data/base.py b/backend/chainlit/data/base.py index 464a13cc78..8e17b7bb65 100644 --- a/backend/chainlit/data/base.py +++ b/backend/chainlit/data/base.py @@ -109,3 +109,7 @@ async def build_debug_url(self) -> str: @abstractmethod async def close(self) -> None: pass + + @abstractmethod + async def get_favorite_steps(self, user_id: str) -> List["StepDict"]: + pass diff --git a/backend/chainlit/data/chainlit_data_layer.py b/backend/chainlit/data/chainlit_data_layer.py index 302bd086d0..6b09d3e595 100644 --- a/backend/chainlit/data/chainlit_data_layer.py +++ b/backend/chainlit/data/chainlit_data_layer.py @@ -631,8 +631,20 @@ async def update_thread( await self.execute_query(query, {str(i + 1): v for i, v in enumerate(values)}) + async def get_favorite_steps(self, user_id: str) -> List[StepDict]: + query = """ + SELECT s.* + FROM "Step" s + JOIN "Thread" t ON s."threadId" = t.id + WHERE t."userId" = $1 + AND s.metadata::jsonb->>'favorite' = 'true' + ORDER BY s."createdAt" DESC \ + """ + results = await self.execute_query(query, {"user_id": user_id}) + return [self._convert_step_row_to_dict(row) for row in results] + def _extract_feedback_dict_from_step_row(self, row: Dict) -> Optional[FeedbackDict]: - if row["feedback_id"] is not None: + if row.get("feedback_id", None) is not None: return FeedbackDict( forId=row["id"], id=row["feedback_id"], diff --git a/backend/chainlit/data/dynamodb.py b/backend/chainlit/data/dynamodb.py index 74eee5a118..52540f43f3 100644 --- a/backend/chainlit/data/dynamodb.py +++ b/backend/chainlit/data/dynamodb.py @@ -6,7 +6,7 @@ from dataclasses import asdict from datetime import datetime from decimal import Decimal -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast import aiofiles import aiohttp @@ -612,6 +612,69 @@ async def update_thread( updates=item, ) + async def get_favorite_steps(self, user_id: str) -> List["StepDict"]: + _logger.info("DynamoDB: get_favorite_steps user_id=%s", user_id) + + thread_ids = [] + query_args: Dict[str, Any] = { + "TableName": self.table_name, + "IndexName": "UserThread", + "KeyConditionExpression": "#UserThreadPK = :pk", + "ExpressionAttributeNames": {"#UserThreadPK": "UserThreadPK"}, + "ExpressionAttributeValues": {":pk": {"S": f"USER#{user_id}"}}, + } + + while True: + response = self.client.query(**query_args) # type: ignore + for item in response.get("Items", []): + pk = item.get("PK", {}).get("S") + if pk: + thread_ids.append(pk.strip("THREAD#")) + + if "LastEvaluatedKey" not in response: + break + query_args["ExclusiveStartKey"] = response["LastEvaluatedKey"] + + favorite_steps: List[Dict[str, Any]] = [] + + for thread_id in thread_ids: + t_query_args: Dict[str, Any] = { + "TableName": self.table_name, + "KeyConditionExpression": "#pk = :pk AND begins_with(#sk, :sk_prefix)", + "FilterExpression": "#metadata.#favorite = :true", + "ExpressionAttributeNames": { + "#pk": "PK", + "#sk": "SK", + "#metadata": "metadata", + "#favorite": "favorite", + }, + "ExpressionAttributeValues": { + ":pk": {"S": f"THREAD#{thread_id}"}, + ":sk_prefix": {"S": "STEP#"}, + ":true": {"BOOL": True}, + }, + } + + while True: + response = self.client.query(**t_query_args) # type: ignore + for item in response.get("Items", []): + step = self._deserialize_item(item) + if "PK" in step: + del step["PK"] + if "SK" in step: + del step["SK"] + if "feedback" in step: + del step["feedback"] + + favorite_steps.append(step) + + if "LastEvaluatedKey" not in response: + break + t_query_args["ExclusiveStartKey"] = response["LastEvaluatedKey"] + + favorite_steps.sort(key=lambda x: x.get("createdAt", ""), reverse=True) + return cast(List["StepDict"], favorite_steps) + async def build_debug_url(self) -> str: return "" diff --git a/backend/chainlit/data/literalai.py b/backend/chainlit/data/literalai.py index 2d750b6f86..04e0c5be5a 100644 --- a/backend/chainlit/data/literalai.py +++ b/backend/chainlit/data/literalai.py @@ -516,5 +516,9 @@ async def update_thread( tags=tags, ) + async def get_favorite_steps(self, user_id: str) -> List[StepDict]: + """noop for literalai""" + return [] + async def close(self): self.client.flush_and_stop() diff --git a/backend/chainlit/data/sql_alchemy.py b/backend/chainlit/data/sql_alchemy.py index a46c088b66..2f2fea0d82 100644 --- a/backend/chainlit/data/sql_alchemy.py +++ b/backend/chainlit/data/sql_alchemy.py @@ -777,6 +777,80 @@ async def get_all_user_threads( return list(thread_dicts.values()) + async def get_favorite_steps(self, user_id: str) -> List[StepDict]: + if self.show_logger: + logger.info(f"SQLAlchemy: get_favorite_steps, user_id={user_id}") + + query = """ + SELECT + s."id" AS step_id, + s."name" AS step_name, + s."type" AS step_type, + s."threadId" AS step_threadid, + s."parentId" AS step_parentid, + s."streaming" AS step_streaming, + s."waitForAnswer" AS step_waitforanswer, + s."isError" AS step_iserror, + s."metadata" AS step_metadata, + s."tags" AS step_tags, + s."input" AS step_input, + s."output" AS step_output, + s."createdAt" AS step_createdat, + s."start" AS step_start, + s."end" AS step_end, + s."generation" AS step_generation, + s."showInput" AS step_showinput, + s."language" AS step_language + FROM steps s + JOIN threads t ON s."threadId" = t.id + WHERE t."userId" = :user_id + AND s."metadata" LIKE :favorite_pattern + ORDER BY s."createdAt" DESC \ + """ + + result = await self.execute_sql( + query, {"user_id": user_id, "favorite_pattern": '%"favorite": true%'} + ) + + steps = [] + if isinstance(result, list): + for row in result: + metadata_raw = row["step_metadata"] + meta_dict = {} + if isinstance(metadata_raw, str): + try: + meta_dict = json.loads(metadata_raw) + except Exception: + pass + elif isinstance(metadata_raw, dict): + meta_dict = metadata_raw + + if meta_dict.get("favorite"): + steps.append( + StepDict( + id=row["step_id"], + name=row["step_name"], + type=row["step_type"], + threadId=row["step_threadid"], + parentId=row["step_parentid"], + streaming=row.get("step_streaming", False), + waitForAnswer=row.get("step_waitforanswer"), + isError=row.get("step_iserror"), + metadata=meta_dict, + tags=row.get("step_tags"), + input=row.get("step_input", ""), + output=row.get("step_output", ""), + createdAt=row.get("step_createdat"), + start=row.get("step_start"), + end=row.get("step_end"), + generation=row.get("step_generation"), + showInput=row.get("step_showinput"), + language=row.get("step_language"), + feedback=None, + ) + ) + return steps + async def close(self) -> None: if self.storage_provider: await self.storage_provider.close() diff --git a/backend/chainlit/emitter.py b/backend/chainlit/emitter.py index 252ab7a6e1..054f8aa9e1 100644 --- a/backend/chainlit/emitter.py +++ b/backend/chainlit/emitter.py @@ -155,6 +155,10 @@ def send_toast(self, message: str, type: Optional[ToastType] = "info"): """Stub method to send a toast message to the UI.""" pass + async def set_favorites(self, steps: List[StepDict]): + """Stub method to send the favorite messages to the UI.""" + pass + class ChainlitEmitter(BaseChainlitEmitter): """ @@ -450,6 +454,13 @@ def set_modes(self, modes: List[Mode]): [mode.to_dict() for mode in modes], ) + def set_favorites(self, steps: List[StepDict]): + """Send the favorite messages to the UI.""" + return self.emit( + "set_favorites", + steps, + ) + def send_window_message(self, data: Any): """Send custom data to the host window.""" return self.emit("window_message", data) diff --git a/backend/chainlit/socket.py b/backend/chainlit/socket.py index cbc904c0ac..7e42713eb5 100644 --- a/backend/chainlit/socket.py +++ b/backend/chainlit/socket.py @@ -322,6 +322,36 @@ async def edit_message(sid, payload: MessagePayload): await context.emitter.task_end() +@sio.on("message_favorite") +async def message_favorite(sid, payload: MessagePayload): # pyright: ignore [reportOptionalCall] + """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) + break + + +@sio.on("fetch_favorites") +async def fetch_favorites(sid): + session = WebsocketSession.require(sid) + context = init_ws_context(session) + if session.user and config.features.favorites: + if data_layer := get_data_layer(): + favorites = await data_layer.get_favorite_steps(session.user.id) + await context.emitter.set_favorites(favorites) + + @sio.on("client_message") # pyright: ignore [reportOptionalCall] async def message(sid, payload: MessagePayload): """Handle a message sent by the User.""" diff --git a/frontend/tests/FeedbackButton.spec.tsx b/frontend/tests/FeedbackButton.spec.tsx new file mode 100644 index 0000000000..7f9cc7da9a --- /dev/null +++ b/frontend/tests/FeedbackButton.spec.tsx @@ -0,0 +1,173 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + IStep, + favoriteMessagesState, + useConfig +} from '@chainlit/react-client'; + +import { FavoriteButton } from '@/components/chat/MessageComposer/FavoriteButton'; + +vi.mock('@/components/i18n/Translator', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const trans: Record = { + 'chat.favorites.use': 'Use favorite', + 'chat.favorites.headline': 'Favorites List' + }; + return trans[key] || key; + } + }) +})); + +vi.mock('@chainlit/react-client', async () => { + const { atom } = await import('recoil'); + return { + useConfig: vi.fn(), + favoriteMessagesState: atom({ + key: 'favoriteMessagesState', + default: [] + }) + }; +}); + +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +window.HTMLElement.prototype.scrollIntoView = vi.fn(); + +describe('FavoriteButton', () => { + const mockOnSelect = vi.fn(); + + const mockFavorites: IStep[] = [ + { + id: 'msg_1', + output: 'How do I center a div?', + createdAt: new Date('2023-10-01').getTime(), + type: 'assistant_message', + name: 'Assistant' + }, + { + id: 'msg_2', + output: 'Explain Quantum Physics', + createdAt: new Date('2023-10-05').getTime(), + type: 'assistant_message', + name: 'Assistant' + } + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderComponent = (favorites = mockFavorites, props = {}) => { + return render( + { + set(favoriteMessagesState, favorites); + }} + > + + + ); + }; + + it('returns null if the "favorites" feature is disabled in config', () => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: false } } + }); + + const { container } = renderComponent(); + expect(container.firstChild).toBeNull(); + }); + + it('returns null if there are no favorites in the state', () => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: true } } + }); + + const { container } = renderComponent([]); + expect(container.firstChild).toBeNull(); + }); + + it('renders the button when feature is enabled and favorites exist', () => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: true } } + }); + + renderComponent(); + + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + expect(button.querySelector('svg')).toBeInTheDocument(); + }); + + it('shows tooltip text on hover', async () => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: true } } + }); + + renderComponent(); + + const button = screen.getByRole('button'); + fireEvent.mouseOver(button); + fireEvent.focus(button); + + await waitFor(() => { + const tooltips = screen.getAllByText('Use favorite'); + expect(tooltips.length).toBeGreaterThan(0); + expect(tooltips[0]).toBeInTheDocument(); + }); + }); + + it('opens the popover and displays the list of favorites when clicked', () => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: true } } + }); + + renderComponent(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(screen.getByText('Favorites List')).toBeInTheDocument(); + expect(screen.getByText('How do I center a div?')).toBeInTheDocument(); + expect(screen.getByText('Explain Quantum Physics')).toBeInTheDocument(); + expect( + screen.getByText(new Date('2023-10-01').toLocaleDateString()) + ).toBeInTheDocument(); + }); + + it('triggers onSelect with the correct output when an item is clicked', () => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: true } } + }); + + renderComponent(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const item = screen.getByText('How do I center a div?'); + fireEvent.click(item); + + expect(mockOnSelect).toHaveBeenCalledTimes(1); + expect(mockOnSelect).toHaveBeenCalledWith('How do I center a div?'); + }); + + it('respects the disabled prop', () => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: true } } + }); + + renderComponent(mockFavorites, { disabled: true }); + + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + }); +}); From 735e0361141ea3547368f741d985bd25e3d1d066 Mon Sep 17 00:00:00 2001 From: Michael Eisele Date: Mon, 22 Dec 2025 11:29:36 +0100 Subject: [PATCH 5/9] fix: make tooltip disappear after clicking favorite message --- .../chat/MessageComposer/FavoriteButton.tsx | 53 ++++++++++++- ...utton.spec.tsx => FavoriteButton.spec.tsx} | 79 ++++++++++++++----- 2 files changed, 109 insertions(+), 23 deletions(-) rename frontend/tests/{FeedbackButton.spec.tsx => FavoriteButton.spec.tsx} (72%) diff --git a/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx b/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx index 2e5de17c83..f956d5658c 100644 --- a/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx +++ b/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx @@ -5,7 +5,7 @@ import { PopoverTrigger } from '@radix-ui/react-popover'; import { Star } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useRecoilValue } from 'recoil'; import { favoriteMessagesState, useConfig } from '@chainlit/react-client'; @@ -25,6 +25,8 @@ import { TooltipTrigger } from '@/components/ui/tooltip'; +const TOOLTIP_DELAY_MS = 700; + interface Props { disabled?: boolean; onSelect: (content: string) => void; @@ -34,16 +36,57 @@ export const FavoriteButton = ({ disabled = false, onSelect }: Props) => { const favorites = useRecoilValue(favoriteMessagesState); const { config } = useConfig(); const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [tooltipOpen, setTooltipOpen] = useState(false); + const hoverTimerRef = useRef(null); + + useEffect(() => { + return () => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (open) { + cancelTooltipOpen(); + } + }, [open]); + + const scheduleTooltipOpen = () => { + if (disabled || open) return; + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + } + hoverTimerRef.current = window.setTimeout(() => { + setTooltipOpen(true); + }, TOOLTIP_DELAY_MS); + }; + + const cancelTooltipOpen = () => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + setTooltipOpen(false); + }; if (!config?.features?.favorites) return null; if (!favorites.length) return null; return (
- + { + setOpen(val); + if (val) cancelTooltipOpen(); + }} + > - + @@ -80,6 +126,7 @@ export const FavoriteButton = ({ disabled = false, onSelect }: Props) => { onSelect={() => { onSelect(step.output); setOpen(false); + cancelTooltipOpen(); }} className="cursor-pointer" > diff --git a/frontend/tests/FeedbackButton.spec.tsx b/frontend/tests/FavoriteButton.spec.tsx similarity index 72% rename from frontend/tests/FeedbackButton.spec.tsx rename to frontend/tests/FavoriteButton.spec.tsx index 7f9cc7da9a..2945e1b09c 100644 --- a/frontend/tests/FeedbackButton.spec.tsx +++ b/frontend/tests/FavoriteButton.spec.tsx @@ -1,6 +1,6 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IStep, @@ -63,6 +63,11 @@ describe('FavoriteButton', () => { beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); }); const renderComponent = (favorites = mockFavorites, props = {}) => { @@ -107,24 +112,6 @@ describe('FavoriteButton', () => { expect(button.querySelector('svg')).toBeInTheDocument(); }); - it('shows tooltip text on hover', async () => { - (useConfig as any).mockReturnValue({ - config: { features: { favorites: true } } - }); - - renderComponent(); - - const button = screen.getByRole('button'); - fireEvent.mouseOver(button); - fireEvent.focus(button); - - await waitFor(() => { - const tooltips = screen.getAllByText('Use favorite'); - expect(tooltips.length).toBeGreaterThan(0); - expect(tooltips[0]).toBeInTheDocument(); - }); - }); - it('opens the popover and displays the list of favorites when clicked', () => { (useConfig as any).mockReturnValue({ config: { features: { favorites: true } } @@ -170,4 +157,56 @@ describe('FavoriteButton', () => { const button = screen.getByRole('button'); expect(button).toBeDisabled(); }); + + it('shows tooltip text after delay on hover', async () => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: true } } + }); + + renderComponent(); + const button = screen.getByRole('button'); + fireEvent.mouseEnter(button); + expect(screen.queryByText('Use favorite')).not.toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(800); + }); + + const tooltips = screen.getAllByText('Use favorite'); + expect(tooltips.length).toBeGreaterThan(0); + }); + + it('cancels tooltip if mouse leaves before delay', async () => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: true } } + }); + + renderComponent(); + const button = screen.getByRole('button'); + + fireEvent.mouseEnter(button); + fireEvent.mouseLeave(button); + act(() => { + vi.advanceTimersByTime(800); + }); + expect(screen.queryByText('Use favorite')).not.toBeInTheDocument(); + }); + + it('hides the tooltip instantly when the popover opens', async () => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: true } } + }); + + renderComponent(); + const button = screen.getByRole('button'); + + fireEvent.mouseEnter(button); + act(() => { + vi.advanceTimersByTime(800); + }); + expect(screen.getAllByText('Use favorite').length).toBeGreaterThan(0); + + fireEvent.click(button); + expect(screen.queryByText('Use favorite')).not.toBeInTheDocument(); + expect(screen.getByText('Favorites List')).toBeInTheDocument(); + }); }); From a3a546ba183c9d22aa57ce74e1502904539ca9f1 Mon Sep 17 00:00:00 2001 From: Michael Eisele Date: Tue, 23 Dec 2025 09:17:56 +0100 Subject: [PATCH 6/9] fix: adjust dynamodb and sqlalchemy datalayer to be more consistent with other methods --- backend/chainlit/data/dynamodb.py | 2 +- backend/chainlit/data/sql_alchemy.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/chainlit/data/dynamodb.py b/backend/chainlit/data/dynamodb.py index 52540f43f3..7a87404b7f 100644 --- a/backend/chainlit/data/dynamodb.py +++ b/backend/chainlit/data/dynamodb.py @@ -629,7 +629,7 @@ async def get_favorite_steps(self, user_id: str) -> List["StepDict"]: for item in response.get("Items", []): pk = item.get("PK", {}).get("S") if pk: - thread_ids.append(pk.strip("THREAD#")) + thread_ids.append(pk.removeprefix("THREAD#")) if "LastEvaluatedKey" not in response: break diff --git a/backend/chainlit/data/sql_alchemy.py b/backend/chainlit/data/sql_alchemy.py index 2f2fea0d82..cf8c436777 100644 --- a/backend/chainlit/data/sql_alchemy.py +++ b/backend/chainlit/data/sql_alchemy.py @@ -838,7 +838,11 @@ async def get_favorite_steps(self, user_id: str) -> List[StepDict]: isError=row.get("step_iserror"), metadata=meta_dict, tags=row.get("step_tags"), - input=row.get("step_input", ""), + input=( + row.get("step_input", "") + if row.get("step_showinput") not in [None, "false"] + else "" + ), output=row.get("step_output", ""), createdAt=row.get("step_createdat"), start=row.get("step_start"), From 4e07ee148263db25d007605bde9365a3bab56fea Mon Sep 17 00:00:00 2001 From: Michael Eisele Date: Tue, 23 Dec 2025 09:18:19 +0100 Subject: [PATCH 7/9] fix: remove unnecessary linter ignore comment --- backend/chainlit/socket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/chainlit/socket.py b/backend/chainlit/socket.py index 7e42713eb5..41353758e5 100644 --- a/backend/chainlit/socket.py +++ b/backend/chainlit/socket.py @@ -323,7 +323,7 @@ async def edit_message(sid, payload: MessagePayload): @sio.on("message_favorite") -async def message_favorite(sid, payload: MessagePayload): # pyright: ignore [reportOptionalCall] +async def message_favorite(sid, payload: MessagePayload): """Handle a message favorite toggle.""" session = WebsocketSession.require(sid) init_ws_context(session) From d3595c618b05d791624dbffd942142f6f1acf8f6 Mon Sep 17 00:00:00 2001 From: Michael Eisele Date: Tue, 23 Dec 2025 09:18:37 +0100 Subject: [PATCH 8/9] fix: add missing method to test datalayer --- backend/chainlit/socket.py | 4 ++-- cypress/e2e/data_layer/main.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/chainlit/socket.py b/backend/chainlit/socket.py index 41353758e5..dd969e091b 100644 --- a/backend/chainlit/socket.py +++ b/backend/chainlit/socket.py @@ -322,7 +322,7 @@ async def edit_message(sid, payload: MessagePayload): await context.emitter.task_end() -@sio.on("message_favorite") +@sio.on("message_favorite") # pyright: ignore [reportOptionalCall] async def message_favorite(sid, payload: MessagePayload): """Handle a message favorite toggle.""" session = WebsocketSession.require(sid) @@ -342,7 +342,7 @@ async def message_favorite(sid, payload: MessagePayload): break -@sio.on("fetch_favorites") +@sio.on("fetch_favorites") # pyright: ignore [reportOptionalCall] async def fetch_favorites(sid): session = WebsocketSession.require(sid) context = init_ws_context(session) diff --git a/cypress/e2e/data_layer/main.py b/cypress/e2e/data_layer/main.py index 333d6ab51b..f383607755 100644 --- a/cypress/e2e/data_layer/main.py +++ b/cypress/e2e/data_layer/main.py @@ -211,6 +211,9 @@ async def update_step(self, step_dict: "StepDict"): async def delete_step(self, step_id: str): pass + async def get_favorite_steps(self, user_id: str) -> List["StepDict"]: + return [] + async def build_debug_url(self) -> str: return "" From f8cf94fd13a11fff6f5e3dc6ad7036f24c0acaff Mon Sep 17 00:00:00 2001 From: Michael Eisele Date: Tue, 23 Dec 2025 09:52:47 +0100 Subject: [PATCH 9/9] fix: add missing method to memory data layer --- cypress/e2e/thread_resume/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cypress/e2e/thread_resume/main.py b/cypress/e2e/thread_resume/main.py index 2f6ab41137..4e9a4748ed 100644 --- a/cypress/e2e/thread_resume/main.py +++ b/cypress/e2e/thread_resume/main.py @@ -115,6 +115,9 @@ async def update_thread( if tags is not None: thr["tags"] = tags + async def get_favorite_steps(self, user_id: str) -> List["StepDict"]: + return [] + async def build_debug_url(self) -> str: pass