diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0c3384d1..ec50b83d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import UploadToast from './components/UploadToast'; import Conversation from './conversation/Conversation'; import { SharedConversation } from './conversation/SharedConversation'; import { useDarkTheme, useMediaQuery } from './hooks'; +import useDataInitializer from './hooks/useDataInitializer'; import useTokenAuth from './hooks/useTokenAuth'; import Navigation from './Navigation'; import PageNotFound from './PageNotFound'; @@ -19,6 +20,7 @@ import Notification from './components/Notification'; function AuthWrapper({ children }: { children: React.ReactNode }) { const { isAuthLoading } = useTokenAuth(); + useDataInitializer(isAuthLoading); if (isAuthLoading) { return ( diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx index 1847d8f9..b556033d 100644 --- a/frontend/src/Navigation.tsx +++ b/frontend/src/Navigation.tsx @@ -31,7 +31,6 @@ import { } from './conversation/conversationSlice'; import ConversationTile from './conversation/ConversationTile'; import { useDarkTheme, useMediaQuery } from './hooks'; -import useDefaultDocument from './hooks/useDefaultDocument'; import useTokenAuth from './hooks/useTokenAuth'; import DeleteConvModal from './modals/DeleteConvModal'; import JWTModal from './modals/JWTModal'; @@ -155,7 +154,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { }, [agents, sharedAgents, token, dispatch]); useEffect(() => { - if (!conversations?.data) fetchConversations(); if (queries.length === 0) resetConversation(); }, [conversations?.data, dispatch]); @@ -290,7 +288,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { setNavOpen(!(isMobile || isTablet)); }, [isMobile, isTablet]); - useDefaultDocument(); return ( <> {(isMobile || isTablet) && navOpen && ( diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index c9605a26..d22476af 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -19,7 +19,9 @@ import { selectSelectedAgent, selectSourceDocs, selectToken, + selectPrompts, setSelectedAgent, + setPrompts, } from '../preferences/preferenceSlice'; import PromptsModal from '../preferences/PromptsModal'; import Prompts from '../settings/Prompts'; @@ -38,6 +40,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const token = useSelector(selectToken); const sourceDocs = useSelector(selectSourceDocs); const selectedAgent = useSelector(selectSelectedAgent); + const prompts = useSelector(selectPrompts); const [effectiveMode, setEffectiveMode] = useState(mode); const [agent, setAgent] = useState({ @@ -62,9 +65,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { default_model_id: '', }); const [imageFile, setImageFile] = useState(null); - const [prompts, setPrompts] = useState< - { name: string; id: string; type: string }[] - >([]); const [userTools, setUserTools] = useState([]); const [availableModels, setAvailableModels] = useState([]); const [isSourcePopupOpen, setIsSourcePopupOpen] = useState(false); @@ -401,14 +401,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { })); setUserTools(tools); }; - const getPrompts = async () => { - const response = await userService.getPrompts(token); - if (!response.ok) { - throw new Error('Failed to fetch prompts'); - } - const data = await response.json(); - setPrompts(data); - }; const getModels = async () => { const response = await modelService.getModels(null); if (!response.ok) throw new Error('Failed to fetch models'); @@ -417,7 +409,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { setAvailableModels(transformed); }; getTools(); - getPrompts(); getModels(); }, [token]); @@ -604,7 +595,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { setHasChanges(isChanged); }, [agent, dispatch, effectiveMode, imageFile, jsonSchemaText]); return ( -
+
- + {/* Animated stars background */} +
+ {stars.map((star) => ( + + {/* 4-pointed Christmas star */} + + + ))} +
+ +

+ {notificationText} +

+ + + {/* Arrow tail - grows leftward from arrow head's back point on hover */} + + {/* Arrow head - pushed forward by the tail on hover */} + + + + + + + ); } diff --git a/frontend/src/components/SearchableDropdown.tsx b/frontend/src/components/SearchableDropdown.tsx new file mode 100644 index 00000000..efd2b8a4 --- /dev/null +++ b/frontend/src/components/SearchableDropdown.tsx @@ -0,0 +1,271 @@ +import React from 'react'; + +import Arrow2 from '../assets/dropdown-arrow.svg'; +import Edit from '../assets/edit.svg'; +import Search from '../assets/search.svg'; +import Trash from '../assets/trash.svg'; + +/** + * SearchableDropdown - A standalone dropdown component with built-in search functionality + */ + +type SearchableDropdownOptionBase = { + id?: string; + type?: string; +}; + +type NameIdOption = { name: string; id: string } & SearchableDropdownOptionBase; + +export type SearchableDropdownOption = + | string + | NameIdOption + | ({ label: string; value: string } & SearchableDropdownOptionBase) + | ({ value: number; description: string } & SearchableDropdownOptionBase); + +export type SearchableDropdownSelectedValue = SearchableDropdownOption | null; + +export interface SearchableDropdownProps< + T extends SearchableDropdownOption = SearchableDropdownOption, +> { + options: T[]; + selectedValue: SearchableDropdownSelectedValue; + onSelect: (value: T) => void; + size?: string; + /** Controls border radius for both button and dropdown menu */ + rounded?: 'xl' | '3xl'; + border?: 'border' | 'border-2'; + showEdit?: boolean; + onEdit?: (value: NameIdOption) => void; + showDelete?: boolean | ((option: T) => boolean); + onDelete?: (id: string) => void; + placeholder?: string; +} + +function SearchableDropdown({ + options, + selectedValue, + onSelect, + size = 'w-32', + rounded = 'xl', + border = 'border-2', + showEdit, + onEdit, + showDelete, + onDelete, + placeholder, +}: SearchableDropdownProps) { + const dropdownRef = React.useRef(null); + const searchInputRef = React.useRef(null); + const [isOpen, setIsOpen] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(''); + + const borderRadius = rounded === 'xl' ? 'rounded-xl' : 'rounded-3xl'; + + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + setSearchQuery(''); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + React.useEffect(() => { + if (isOpen && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [isOpen]); + + const getOptionText = (option: SearchableDropdownOption): string => { + if (typeof option === 'string') return option; + if ('name' in option) return option.name; + if ('label' in option) return option.label; + if ('description' in option) return option.description; + return ''; + }; + + const filteredOptions = React.useMemo(() => { + if (!searchQuery.trim()) return options; + const query = searchQuery.toLowerCase(); + return options.filter((option) => + getOptionText(option).toLowerCase().includes(query), + ); + }, [options, searchQuery]); + + const getDisplayValue = (): string => { + if (!selectedValue) return placeholder ?? 'From URL'; + if (typeof selectedValue === 'string') return selectedValue; + if ('label' in selectedValue) return selectedValue.label; + if ('name' in selectedValue) return selectedValue.name; + if ('description' in selectedValue) { + return selectedValue.value < 1e9 + ? `${selectedValue.value} (${selectedValue.description})` + : selectedValue.description; + } + return placeholder ?? 'From URL'; + }; + + const isOptionSelected = (option: T): boolean => { + if (!selectedValue) return false; + if (typeof selectedValue === 'string') + return selectedValue === (option as unknown as string); + if (typeof option === 'string') return false; + + const optionObj = option as Record; + const selectedObj = selectedValue as Record; + + if ('name' in optionObj && 'name' in selectedObj) + return selectedObj.name === optionObj.name; + if ('label' in optionObj && 'label' in selectedObj) + return selectedObj.label === optionObj.label; + if ('value' in optionObj && 'value' in selectedObj) + return selectedObj.value === optionObj.value; + return false; + }; + + return ( +
+ + + {isOpen && ( +
+
+
+ search + setSearchQuery(e.target.value)} + placeholder="Search..." + className="dark:text-bright-gray w-full rounded-lg border-0 bg-transparent py-2 pr-3 pl-10 font-['Inter'] text-[14px] leading-[16.5px] font-normal focus:ring-0 focus:outline-none" + onClick={(e) => e.stopPropagation()} + /> +
+
+ +
+ {filteredOptions.length === 0 ? ( +
+ No results found +
+ ) : ( + filteredOptions.map((option, index) => { + const selected = isOptionSelected(option); + const optionObj = + typeof option !== 'string' + ? (option as Record) + : null; + const optionType = optionObj?.type as string | undefined; + const optionId = optionObj?.id as string | undefined; + const optionName = optionObj?.name as string | undefined; + + return ( +
+ { + onSelect(option); + setIsOpen(false); + setSearchQuery(''); + }} + className="dark:text-light-gray ml-5 flex-1 overflow-hidden py-3 font-['Inter'] text-[14px] leading-[16.5px] font-normal text-ellipsis whitespace-nowrap" + > + {getOptionText(option)} + + {showEdit && + onEdit && + optionObj && + optionType !== 'public' && ( + Edit { + if (optionName && optionId) { + onEdit({ + id: optionId, + name: optionName, + type: optionType, + }); + } + setIsOpen(false); + setSearchQuery(''); + }} + /> + )} + {showDelete && onDelete && ( + + )} +
+ ); + }) + )} +
+
+ )} +
+ ); +} + +export default SearchableDropdown; diff --git a/frontend/src/conversation/ConversationMessages.tsx b/frontend/src/conversation/ConversationMessages.tsx index 0007f5dc..bd099f12 100644 --- a/frontend/src/conversation/ConversationMessages.tsx +++ b/frontend/src/conversation/ConversationMessages.tsx @@ -7,7 +7,6 @@ import { useState, } from 'react'; import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; import ArrowDown from '../assets/arrow-down.svg'; import RetryIcon from '../components/RetryIcon'; @@ -15,7 +14,6 @@ import Hero from '../Hero'; import { useDarkTheme } from '../hooks'; import ConversationBubble from './ConversationBubble'; import { FEEDBACK, Query, Status } from './conversationModels'; -import { selectConversationId } from '../preferences/preferenceSlice'; const SCROLL_THRESHOLD = 10; const LAST_BUBBLE_MARGIN = 'mb-32'; @@ -52,7 +50,6 @@ export default function ConversationMessages({ }: ConversationMessagesProps) { const [isDarkTheme] = useDarkTheme(); const { t } = useTranslation(); - const conversationId = useSelector(selectConversationId); const conversationRef = useRef(null); const [hasScrolledToLast, setHasScrolledToLast] = useState(true); @@ -145,7 +142,7 @@ export default function ConversationMessages({ return ( 0 ? ( queries.map((query, index) => ( - + { + // Skip if auth is still loading + if (isAuthLoading) { + return; + } + + const fetchDocs = async () => { + try { + const data = await getDocs(token); + dispatch(setSourceDocs(data)); + + // Auto-select default document if none selected + if ( + !selectedDoc || + (Array.isArray(selectedDoc) && selectedDoc.length === 0) + ) { + if (Array.isArray(data)) { + data.forEach((doc: Doc) => { + if (doc.model && doc.name === 'default') { + dispatch(setSelectedDocs([doc])); + } + }); + } + } + } catch (error) { + console.error('Failed to fetch documents:', error); + } + }; + + fetchDocs(); + }, [isAuthLoading, token]); + + // Initialize prompts + useEffect(() => { + // Skip if auth is still loading + if (isAuthLoading) { + return; + } + + const fetchPromptsData = async () => { + try { + const data = await getPrompts(token); + dispatch(setPrompts(data)); + } catch (error) { + console.error('Failed to fetch prompts:', error); + } + }; + + fetchPromptsData(); + }, [isAuthLoading, token]); + + // Initialize conversations + useEffect(() => { + // Skip if auth is still loading + if (isAuthLoading) { + return; + } + + const fetchConversationsData = async () => { + if (!conversations?.data) { + dispatch(setConversations({ ...conversations, loading: true })); + try { + const fetchedConversations = await getConversations(token); + dispatch(setConversations(fetchedConversations)); + } catch (error) { + console.error('Failed to fetch conversations:', error); + dispatch(setConversations({ data: null, loading: false })); + } + } + }; + + fetchConversationsData(); + }, [isAuthLoading, conversations?.data, token]); +} diff --git a/frontend/src/hooks/useDefaultDocument.ts b/frontend/src/hooks/useDefaultDocument.ts deleted file mode 100644 index 17568c59..00000000 --- a/frontend/src/hooks/useDefaultDocument.ts +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; -import { useDispatch, useSelector } from 'react-redux'; - -import { Doc } from '../models/misc'; -import { getDocs } from '../preferences/preferenceApi'; -import { - selectSelectedDocs, - selectToken, - setSelectedDocs, - setSourceDocs, -} from '../preferences/preferenceSlice'; - -export default function useDefaultDocument() { - const dispatch = useDispatch(); - const token = useSelector(selectToken); - const selectedDoc = useSelector(selectSelectedDocs); - - const fetchDocs = () => { - getDocs(token).then((data) => { - dispatch(setSourceDocs(data)); - if ( - !selectedDoc || - (Array.isArray(selectedDoc) && selectedDoc.length === 0) - ) - Array.isArray(data) && - data?.forEach((doc: Doc) => { - if (doc.model && doc.name === 'default') { - dispatch(setSelectedDocs([doc])); - } - }); - }); - }; - - React.useEffect(() => { - fetchDocs(); - }, []); -} diff --git a/frontend/src/preferences/preferenceApi.ts b/frontend/src/preferences/preferenceApi.ts index b9c199e2..39396959 100644 --- a/frontend/src/preferences/preferenceApi.ts +++ b/frontend/src/preferences/preferenceApi.ts @@ -1,6 +1,6 @@ import conversationService from '../api/services/conversationService'; import userService from '../api/services/userService'; -import { Doc, GetDocsResponse } from '../models/misc'; +import { Doc, GetDocsResponse, Prompt } from '../models/misc'; import { GetConversationsResult, ConversationSummary } from './types'; //Fetches all JSON objects from the source. We only use the objects with the "model" property in SelectDocsModal.tsx. Hopefully can clean up the source file later. @@ -113,17 +113,40 @@ export function getLocalRecentDocs(sourceDocs?: Doc[] | null): Doc[] | null { return validDocs.length > 0 ? validDocs : null; } -export function getLocalPrompt(): string | null { - const prompt = localStorage.getItem('DocsGPTPrompt'); - return prompt; +export function getLocalPrompt( + availablePrompts?: Prompt[] | null, +): Prompt | null { + const promptString = localStorage.getItem('DocsGPTPrompt'); + const selectedPrompt = promptString + ? (JSON.parse(promptString) as Prompt) + : null; + + if (!availablePrompts || !selectedPrompt) { + return selectedPrompt; + } + + const isPromptAvailable = (selected: Prompt) => { + return availablePrompts.some((available) => { + return available.id === selected.id; + }); + }; + + const isValid = isPromptAvailable(selectedPrompt); + + if (!isValid) { + localStorage.removeItem('DocsGPTPrompt'); + return null; + } + + return selectedPrompt; } export function setLocalApiKey(key: string): void { localStorage.setItem('DocsGPTApiKey', key); } -export function setLocalPrompt(prompt: string): void { - localStorage.setItem('DocsGPTPrompt', prompt); +export function setLocalPrompt(prompt: Prompt): void { + localStorage.setItem('DocsGPTPrompt', JSON.stringify(prompt)); } export function setLocalRecentDocs(docs: Doc[] | null): void { @@ -133,3 +156,17 @@ export function setLocalRecentDocs(docs: Doc[] | null): void { localStorage.removeItem('DocsGPTRecentDocs'); } } + +export async function getPrompts(token: string | null): Promise { + try { + const response = await userService.getPrompts(token); + if (!response.ok) { + throw new Error('Failed to fetch prompts'); + } + const data = await response.json(); + return data as Prompt[]; + } catch (error) { + console.error('Error fetching prompts:', error); + return []; + } +} diff --git a/frontend/src/preferences/preferenceSlice.ts b/frontend/src/preferences/preferenceSlice.ts index b3fa7fcd..2bd5faea 100644 --- a/frontend/src/preferences/preferenceSlice.ts +++ b/frontend/src/preferences/preferenceSlice.ts @@ -6,9 +6,10 @@ import { } from '@reduxjs/toolkit'; import { Agent } from '../agents/types'; -import { ActiveState, Doc } from '../models/misc'; +import { ActiveState, Doc, Prompt } from '../models/misc'; import { RootState } from '../store'; import { + getLocalPrompt, getLocalRecentDocs, setLocalApiKey, setLocalRecentDocs, @@ -18,6 +19,7 @@ import type { Model } from '../models/types'; export interface Preference { apiKey: string; prompt: { name: string; id: string; type: string }; + prompts: Prompt[]; chunks: string; token_limit: number; selectedDocs: Doc[]; @@ -41,6 +43,11 @@ export interface Preference { const initialState: Preference = { apiKey: 'xxx', prompt: { name: 'default', id: 'default', type: 'public' }, + prompts: [ + { name: 'default', id: 'default', type: 'public' }, + { name: 'creative', id: 'creative', type: 'public' }, + { name: 'strict', id: 'strict', type: 'public' }, + ], chunks: '2', token_limit: 2000, selectedDocs: [ @@ -95,6 +102,9 @@ export const prefSlice = createSlice({ setPrompt: (state, action) => { state.prompt = action.payload; }, + setPrompts: (state, action: PayloadAction) => { + state.prompts = action.payload; + }, setChunks: (state, action) => { state.chunks = action.payload; }, @@ -135,6 +145,7 @@ export const { setConversations, setToken, setPrompt, + setPrompts, setChunks, setTokenLimit, setModalStateDeleteConv, @@ -217,6 +228,27 @@ prefListenerMiddleware.startListening({ }, }); +prefListenerMiddleware.startListening({ + matcher: isAnyOf(setPrompts), + effect: (_action, listenerApi) => { + const state = listenerApi.getState() as RootState; + const availablePrompts = state.preference.prompts; + if (availablePrompts && availablePrompts.length > 0) { + const validatedPrompt = getLocalPrompt(availablePrompts); + if (validatedPrompt !== null) { + listenerApi.dispatch(setPrompt(validatedPrompt)); + } else { + const defaultPrompt = + availablePrompts.find((p) => p.id === 'default') || + availablePrompts[0]; + if (defaultPrompt) { + listenerApi.dispatch(setPrompt(defaultPrompt)); + } + } + } + }, +}); + prefListenerMiddleware.startListening({ matcher: isAnyOf(setSelectedModel), effect: (action, listenerApi) => { @@ -247,6 +279,7 @@ export const selectConversationId = (state: RootState) => state.conversation.conversationId; export const selectToken = (state: RootState) => state.preference.token; export const selectPrompt = (state: RootState) => state.preference.prompt; +export const selectPrompts = (state: RootState) => state.preference.prompts; export const selectChunks = (state: RootState) => state.preference.chunks; export const selectTokenLimit = (state: RootState) => state.preference.token_limit; diff --git a/frontend/src/settings/General.tsx b/frontend/src/settings/General.tsx index 442f7076..8b2c53cc 100644 --- a/frontend/src/settings/General.tsx +++ b/frontend/src/settings/General.tsx @@ -2,17 +2,17 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import userService from '../api/services/userService'; import Dropdown from '../components/Dropdown'; import { useDarkTheme } from '../hooks'; import { selectChunks, selectPrompt, - selectToken, + selectPrompts, selectTokenLimit, setChunks, setModalStateDeleteConv, setPrompt, + setPrompts, setTokenLimit, } from '../preferences/preferenceSlice'; import Prompts from './Prompts'; @@ -22,7 +22,6 @@ export default function General() { t, i18n: { changeLanguage }, } = useTranslation(); - const token = useSelector(selectToken); const themes = [ { value: 'Light', label: t('settings.general.light') }, { value: 'Dark', label: t('settings.general.dark') }, @@ -46,9 +45,7 @@ export default function General() { [4000, t('settings.general.high')], [1e9, t('settings.general.unlimited')], ]); - const [prompts, setPrompts] = React.useState< - { name: string; id: string; type: string }[] - >([]); + const prompts = useSelector(selectPrompts); const selectedChunks = useSelector(selectChunks); const selectedTokenLimit = useSelector(selectTokenLimit); const [isDarkTheme, toggleTheme] = useDarkTheme(); @@ -64,22 +61,6 @@ export default function General() { ); const selectedPrompt = useSelector(selectPrompt); - React.useEffect(() => { - const handleFetchPrompts = async () => { - try { - const response = await userService.getPrompts(token); - if (!response.ok) { - throw new Error('Failed to fetch prompts'); - } - const promptsData = await response.json(); - setPrompts(promptsData); - } catch (error) { - console.error(error); - } - }; - handleFetchPrompts(); - }, []); - React.useEffect(() => { localStorage.setItem('docsgpt-locale', selectedLanguage?.value as string); changeLanguage(selectedLanguage?.value); @@ -169,7 +150,7 @@ export default function General() { onSelectPrompt={(name, id, type) => dispatch(setPrompt({ name: name, id: id, type: type })) } - setPrompts={setPrompts} + setPrompts={(newPrompts) => dispatch(setPrompts(newPrompts))} dropdownProps={{ size: 'w-56', rounded: '3xl', border: 'border' }} />
diff --git a/frontend/src/settings/Prompts.tsx b/frontend/src/settings/Prompts.tsx index d4de6b42..183b3751 100644 --- a/frontend/src/settings/Prompts.tsx +++ b/frontend/src/settings/Prompts.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import userService from '../api/services/userService'; -import Dropdown from '../components/Dropdown'; +import SearchableDropdown from '../components/SearchableDropdown'; import { DropdownProps } from '../components/types/Dropdown.types'; import ConfirmationModal from '../modals/ConfirmationModal'; import { ActiveState, PromptProps } from '../models/misc'; @@ -103,7 +103,12 @@ export default function Prompts({ if (!response.ok) { throw new Error('Failed to delete prompt'); } - if (prompts.length > 0) { + // Only change selection if we're deleting the currently selected prompt + if ( + prompts.length > 0 && + selectedPrompt && + selectedPrompt.id === promptToDelete.id + ) { const firstPrompt = prompts.find((p) => p.id !== promptToDelete.id); if (firstPrompt) { onSelectPrompt( @@ -182,7 +187,7 @@ export default function Prompts({ {title ? title : t('settings.general.prompt')}

- typeof prompt === 'string' ? { name: prompt, id: prompt, type: '' } diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 45b29916..a3092c82 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -25,6 +25,11 @@ const preloadedState: { preference: Preference } = { prompt !== null ? JSON.parse(prompt) : { name: 'default', id: 'default', type: 'private' }, + prompts: [ + { name: 'default', id: 'default', type: 'public' }, + { name: 'creative', id: 'creative', type: 'public' }, + { name: 'strict', id: 'strict', type: 'public' }, + ], chunks: JSON.parse(chunks ?? '2').toString(), token_limit: token_limit ? parseInt(token_limit) : 2000, selectedDocs: doc !== null ? JSON.parse(doc) : [],