Fixes: re-blink in converstaion, (refactor) prompts and validate LocalStorage prompts (#2181)

* chore(dependabot): add react-widget npm dependency updates

* refactor(prompts): init on load, mv to pref slice

* (refactor): searchable dropdowns are separate

* (fix/ui) prompts adjust

* feat(changelog): dancing stars

* (fix)conversation: re-blink bubble past stream

* (fix)endless GET sources, esling err

---------

Co-authored-by: GH Action - Upstream Sync <action@github.com>
This commit is contained in:
Manish Madan
2025-12-11 03:23:40 +05:30
committed by GitHub
parent 4adffe762a
commit 09e7c1b97f
14 changed files with 613 additions and 129 deletions

View File

@@ -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 (

View File

@@ -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 && (

View File

@@ -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<Agent>({
@@ -62,9 +65,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
default_model_id: '',
});
const [imageFile, setImageFile] = useState<File | null>(null);
const [prompts, setPrompts] = useState<
{ name: string; id: string; type: string }[]
>([]);
const [userTools, setUserTools] = useState<OptionType[]>([]);
const [availableModels, setAvailableModels] = useState<Model[]>([]);
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 (
<div className="flex flex-col px-4 pt-4 pb-2 max-[1179px]:min-h-[100dvh] min-[1180px]:h-[100dvh] md:px-12 md:pt-12 md:pb-3">
<div className="flex flex-col px-4 pt-4 pb-2 max-[1179px]:min-h-dvh min-[1180px]:h-dvh md:px-12 md:pt-12 md:pb-3">
<div className="flex items-center gap-3 px-4">
<button
className="rounded-full border p-3 text-sm text-gray-400 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34]"
@@ -834,12 +825,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
prompts={prompts}
selectedPrompt={
prompts.find((prompt) => prompt.id === agent.prompt_id) ||
prompts[0]
prompts[0] || {
name: 'default',
id: 'default',
type: 'public',
}
}
onSelectPrompt={(name, id, type) =>
setAgent({ ...agent, prompt_id: id })
}
setPrompts={setPrompts}
setPrompts={(newPrompts) => dispatch(setPrompts(newPrompts))}
title={t('agents.form.sections.prompt')}
titleClassName="text-lg font-semibold dark:text-[#E0E0E0]"
showAddButton={false}
@@ -1235,7 +1230,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
/>
<AddPromptModal
prompts={prompts}
setPrompts={setPrompts}
isOpen={addPromptModal}
onClose={() => setAddPromptModal('INACTIVE')}
onSelect={(name: string, id: string, type: string) => {
@@ -1269,17 +1263,16 @@ function AgentPreviewArea() {
function AddPromptModal({
prompts,
setPrompts,
isOpen,
onClose,
onSelect,
}: {
prompts: Prompt[];
setPrompts?: React.Dispatch<React.SetStateAction<Prompt[]>>;
isOpen: ActiveState;
onClose: () => void;
onSelect?: (name: string, id: string, type: string) => void;
}) {
const dispatch = useDispatch();
const token = useSelector(selectToken);
const [newPromptName, setNewPromptName] = useState('');
@@ -1298,12 +1291,13 @@ function AddPromptModal({
throw new Error('Failed to add prompt');
}
const newPrompt = await response.json();
if (setPrompts) {
// Update Redux store with new prompt
dispatch(
setPrompts([
...prompts,
{ name: newPromptName, id: newPrompt.id, type: 'private' },
]);
}
]),
);
onClose();
setNewPromptName('');
setNewPromptContent('');

View File

@@ -1,3 +1,3 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.4798 10.739C9.27414 11.6748 7.7572 12.116 6.23773 11.9728C4.71826 11.8296 3.31047 11.1127 2.30094 9.96806C1.2914 8.82345 0.756002 7.33714 0.803717 5.81168C0.851432 4.28622 1.47868 2.83628 2.55777 1.75699C3.63706 0.677895 5.087 0.0506505 6.61246 0.00293578C8.13792 -0.044779 9.62423 0.490623 10.7688 1.50016C11.9135 2.50969 12.6303 3.91747 12.7736 5.43694C12.9168 6.95641 12.4756 8.47336 11.5398 9.67899L14.5798 12.719C14.6785 12.8107 14.7507 12.9273 14.7887 13.0565C14.8267 13.1858 14.8291 13.3229 14.7958 13.4534C14.7624 13.5839 14.6944 13.703 14.5991 13.7982C14.5037 13.8933 14.3844 13.961 14.2538 13.994C14.1234 14.0274 13.9864 14.0251 13.8573 13.9872C13.7281 13.9494 13.6115 13.8775 13.5198 13.779L10.4798 10.739ZM11.2998 5.99899C11.3087 5.4026 11.1989 4.81039 10.9768 4.25681C10.7547 3.70323 10.4248 3.19934 10.0062 2.77445C9.58757 2.34955 9.08865 2.01214 8.53844 1.78183C7.98824 1.55152 7.39773 1.43292 6.80127 1.43292C6.20481 1.43292 5.6143 1.55152 5.0641 1.78183C4.5139 2.01214 4.01498 2.34955 3.59637 2.77445C3.17777 3.19934 2.84783 3.70323 2.62575 4.25681C2.40367 4.81039 2.29388 5.4026 2.30277 5.99899C2.32039 7.18045 2.80208 8.30756 3.6438 9.13682C4.48552 9.96608 5.61968 10.4309 6.80127 10.4309C7.98286 10.4309 9.11703 9.96608 9.95874 9.13682C10.8005 8.30756 11.2822 7.18045 11.2998 5.99899Z" fill="#59636E"/>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.57031 0C10.1985 0.000253697 13.1396 2.94204 13.1396 6.57031C13.1396 7.77747 12.8128 8.90773 12.2442 9.87947C12.1066 10.1147 12.0378 10.2323 12.0243 10.3313C12.0115 10.4257 12.0212 10.5011 12.0575 10.5891C12.0956 10.6815 12.1813 10.7673 12.3528 10.9387L13.6914 12.2773C14.0817 12.6679 14.0818 13.301 13.6914 13.6914C13.3009 14.0815 12.6678 14.0816 12.2773 13.6914L10.9387 12.3528C10.7673 12.1813 10.6815 12.0956 10.5891 12.0575C10.5011 12.0212 10.4257 12.0115 10.3313 12.0243C10.2323 12.0378 10.1147 12.1066 9.87947 12.2442C8.90773 12.8128 7.77747 13.1396 6.57031 13.1396C2.94204 13.1396 0.000253694 10.1985 0 6.57031C0 2.94189 2.94189 0 6.57031 0ZM6.57031 2C4.04646 2 2 4.04646 2 6.57031C2.00025 9.09395 4.04661 11.1396 6.57031 11.1396C9.09379 11.1394 11.1394 9.09379 11.1396 6.57031C11.1396 4.04661 9.09395 2.00025 6.57031 2Z" fill="#6F6F6F"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 960 B

View File

@@ -1,7 +1,5 @@
import { useTranslation } from 'react-i18next';
import close from '../assets/cross.svg';
import rightArrow from '../assets/arrow-full-right.svg';
import bg from '../assets/notification-bg.jpg';
interface NotificationProps {
notificationText: string;
@@ -9,6 +7,16 @@ interface NotificationProps {
handleCloseNotification: () => void;
}
const stars = Array.from({ length: 12 }, (_, i) => ({
id: i,
size: Math.random() * 2 + 1, // 1-3px
left: Math.random() * 100, // 0-100%
top: Math.random() * 100, // 0-100%
animationDuration: Math.random() * 3 + 2, // 2-5s
animationDelay: Math.random() * 2, // 0-2s
opacity: Math.random() * 0.5 + 0.3, // 0.3-0.8
}));
export default function Notification({
notificationText,
notificationLink,
@@ -16,32 +24,112 @@ export default function Notification({
}: NotificationProps) {
const { t } = useTranslation();
return (
<a
className="absolute right-2 bottom-6 z-20 flex w-3/4 items-center justify-center gap-2 rounded-lg bg-cover bg-center bg-no-repeat px-2 py-4 sm:right-4 md:w-2/5 lg:w-1/3 xl:w-1/4 2xl:w-1/5"
style={{ backgroundImage: `url(${bg})` }}
href={notificationLink}
target="_blank"
aria-label={t('notification.ariaLabel')}
rel="noreferrer"
>
<p className="text-white-3000 text-xs leading-6 font-semibold xl:text-sm xl:leading-7">
{notificationText}
</p>
<span>
<img className="w-full" src={rightArrow} alt="" />
</span>
<>
<style>{`
@keyframes twinkle {
0%, 100% {
opacity: 0.3;
transform: scale(1) translateY(0);
}
50% {
opacity: 1;
transform: scale(1.2) translateY(-2px);
}
}
<button
className="absolute top-2 right-2 z-30 h-4 w-4 hover:opacity-70"
aria-label={t('notification.closeAriaLabel')}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleCloseNotification();
.star {
animation: twinkle var(--duration) ease-in-out infinite;
animation-delay: var(--delay);
}
`}</style>
<a
className="group absolute right-2 bottom-6 z-20 flex w-3/4 items-center justify-center gap-2 overflow-hidden rounded-lg px-2 py-4 sm:right-4 md:w-2/5 lg:w-1/3 xl:w-1/4 2xl:w-1/5"
style={{
background:
'linear-gradient(90deg, #390086 0%, #6222B7 100%), linear-gradient(90deg, rgba(57, 0, 134, 0) 0%, #6222B7 53.02%, rgba(57, 0, 134, 0) 100%)',
}}
href={notificationLink}
target="_blank"
aria-label={t('notification.ariaLabel')}
rel="noreferrer"
>
<img className="w-full" src={close} alt="Close notification" />
</button>
</a>
{/* Animated stars background */}
<div className="pointer-events-none absolute inset-0">
{stars.map((star) => (
<svg
key={star.id}
className="star absolute"
style={
{
width: `${star.size * 4}px`,
height: `${star.size * 4}px`,
left: `${star.left}%`,
top: `${star.top}%`,
opacity: star.opacity,
filter: `drop-shadow(0 0 ${star.size}px rgba(255, 255, 255, 0.5))`,
'--duration': `${star.animationDuration}s`,
'--delay': `${star.animationDelay}s`,
} as React.CSSProperties & {
'--duration': string;
'--delay': string;
}
}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* 4-pointed Christmas star */}
<path
d="M12 0L13.5 10.5L24 12L13.5 13.5L12 24L10.5 13.5L0 12L10.5 10.5L12 0Z"
fill="white"
/>
</svg>
))}
</div>
<p className="text-white-3000 relative z-10 text-xs leading-6 font-semibold xl:text-sm xl:leading-7">
{notificationText}
</p>
<span className="relative z-10 flex items-center">
<svg
width="18"
height="13"
viewBox="0 0 18 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="overflow-visible"
>
{/* Arrow tail - grows leftward from arrow head's back point on hover */}
<rect
x="4"
y="5.75"
width="8"
height="1.5"
fill="white"
className="scale-x-0 transition-transform duration-300 ease-out group-hover:scale-x-100"
style={{ transformOrigin: '12px 6.5px' }}
/>
{/* Arrow head - pushed forward by the tail on hover */}
<path
d="M13.0303 7.03033C13.3232 6.73744 13.3232 6.26256 13.0303 5.96967L8.25736 1.1967C7.96447 0.903806 7.48959 0.903806 7.1967 1.1967C6.90381 1.48959 6.90381 1.96447 7.1967 2.25736L11.4393 6.5L7.1967 10.7426C6.90381 11.0355 6.90381 11.5104 7.1967 11.8033C7.48959 12.0962 7.96447 12.0962 8.25736 11.8033L13.0303 7.03033Z"
fill="white"
className="transition-transform duration-300 ease-out group-hover:translate-x-1"
/>
</svg>
</span>
<button
className="absolute top-2 right-2 z-30 h-4 w-4 hover:opacity-70"
aria-label={t('notification.closeAriaLabel')}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleCloseNotification();
}}
>
<img className="w-full" src={close} alt="Close notification" />
</button>
</a>
</>
);
}

View File

@@ -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<T extends SearchableDropdownOption>({
options,
selectedValue,
onSelect,
size = 'w-32',
rounded = 'xl',
border = 'border-2',
showEdit,
onEdit,
showDelete,
onDelete,
placeholder,
}: SearchableDropdownProps<T>) {
const dropdownRef = React.useRef<HTMLDivElement>(null);
const searchInputRef = React.useRef<HTMLInputElement>(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<string, unknown>;
const selectedObj = selectedValue as Record<string, unknown>;
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 (
<div
className={`relative ${typeof selectedValue === 'string' ? '' : 'align-middle'} ${size}`}
ref={dropdownRef}
>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex w-full cursor-pointer items-center justify-between ${border} border-silver dark:border-dim-gray bg-white px-5 py-3 dark:bg-transparent ${borderRadius}`}
>
<span
className={`dark:text-bright-gray truncate ${!selectedValue ? 'text-gray-500 dark:text-gray-400' : ''}`}
>
{getDisplayValue()}
</span>
<img
src={Arrow2}
alt="arrow"
className={`h-3 w-3 transform transition-transform ${isOpen ? 'rotate-180' : 'rotate-0'}`}
/>
</button>
{isOpen && (
<div
className={`absolute right-0 left-0 z-20 mt-2 ${borderRadius} dark:bg-dark-charcoal bg-[#FBFBFB] shadow-[0px_24px_48px_0px_#00000029]`}
>
<div
className={`border-silver dark:border-dim-gray dark:bg-dark-charcoal sticky top-0 z-10 border-b bg-[#FBFBFB] px-3 py-2 ${rounded === 'xl' ? 'rounded-t-xl' : 'rounded-t-3xl'}`}
>
<div className="relative flex items-center">
<img
src={Search}
alt="search"
width={14}
height={14}
className="absolute left-3"
/>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => 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()}
/>
</div>
</div>
<div className="max-h-40 overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="px-5 py-3 text-center text-sm text-gray-500 dark:text-gray-400">
No results found
</div>
) : (
filteredOptions.map((option, index) => {
const selected = isOptionSelected(option);
const optionObj =
typeof option !== 'string'
? (option as Record<string, unknown>)
: null;
const optionType = optionObj?.type as string | undefined;
const optionId = optionObj?.id as string | undefined;
const optionName = optionObj?.name as string | undefined;
return (
<div
key={index}
className={`flex cursor-pointer items-center justify-between hover:bg-[#ECECEC] dark:hover:bg-[#545561] ${selected ? 'bg-[#ECECEC] dark:bg-[#545561]' : ''}`}
>
<span
onClick={() => {
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)}
</span>
{showEdit &&
onEdit &&
optionObj &&
optionType !== 'public' && (
<img
src={Edit}
alt="Edit"
className="mr-4 h-4 w-4 cursor-pointer hover:opacity-50"
onClick={() => {
if (optionName && optionId) {
onEdit({
id: optionId,
name: optionName,
type: optionType,
});
}
setIsOpen(false);
setSearchQuery('');
}}
/>
)}
{showDelete && onDelete && (
<button
onClick={(e) => {
e.stopPropagation();
const id =
typeof option === 'string'
? option
: (optionId ?? '');
onDelete(id);
}}
className={`mr-2 h-4 w-4 cursor-pointer hover:opacity-50 ${
typeof showDelete === 'function' &&
!showDelete(option)
? 'hidden'
: ''
}`}
>
<img
src={Trash}
alt="Delete"
className={`mr-2 h-4 w-4 cursor-pointer hover:opacity-50 ${
optionType === 'public'
? 'cursor-not-allowed opacity-50'
: ''
}`}
/>
</button>
)}
</div>
);
})
)}
</div>
</div>
)}
</div>
);
}
export default SearchableDropdown;

View File

@@ -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<HTMLDivElement>(null);
const [hasScrolledToLast, setHasScrolledToLast] = useState(true);
@@ -145,7 +142,7 @@ export default function ConversationMessages({
return (
<ConversationBubble
className={bubbleMargin}
key={`${conversationId}-${index}-ANSWER`}
key={`${index}-ANSWER`}
message={query.response}
type={'ANSWER'}
thought={query.thought}
@@ -183,7 +180,7 @@ export default function ConversationMessages({
return (
<ConversationBubble
className={bubbleMargin}
key={`${conversationId}-${index}-ERROR`}
key={`${index}-ERROR`}
message={query.error}
type="ERROR"
retryBtn={retryButton}
@@ -222,10 +219,10 @@ export default function ConversationMessages({
{queries.length > 0 ? (
queries.map((query, index) => (
<Fragment key={`${conversationId}-${index}-query-fragment`}>
<Fragment key={`${index}-query-fragment`}>
<ConversationBubble
className={index === 0 ? FIRST_QUESTION_BUBBLE_MARGIN_TOP : ''}
key={`${conversationId}-${index}-QUESTION`}
key={`${index}-QUESTION`}
message={query.prompt}
type="QUESTION"
handleUpdatedQuestionSubmission={handleQuestionSubmission}

View File

@@ -0,0 +1,111 @@
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Doc } from '../models/misc';
import {
getDocs,
getConversations,
getPrompts,
} from '../preferences/preferenceApi';
import {
selectConversations,
selectSelectedDocs,
selectToken,
setConversations,
setPrompts,
setSelectedDocs,
setSourceDocs,
} from '../preferences/preferenceSlice';
/**
* useDataInitializer Hook
*
* Custom hook responsible for initializing all application data on mount.
* This hook handles:
* - Fetching and setting up documents (source docs and selected docs)
* - Fetching and setting up prompts
* - Fetching and setting up conversations
*
* @param isAuthLoading -
*/
export default function useDataInitializer(isAuthLoading: boolean) {
const dispatch = useDispatch();
const token = useSelector(selectToken);
const selectedDoc = useSelector(selectSelectedDocs);
const conversations = useSelector(selectConversations);
// Initialize documents
useEffect(() => {
// 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]);
}

View File

@@ -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();
}, []);
}

View File

@@ -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<Prompt[]> {
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 [];
}
}

View File

@@ -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<Prompt[]>) => {
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;

View File

@@ -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' }}
/>
</div>

View File

@@ -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')}
</p>
<div className="flex flex-row flex-wrap items-baseline justify-start gap-6">
<Dropdown
<SearchableDropdown
options={prompts.map((prompt: any) =>
typeof prompt === 'string'
? { name: prompt, id: prompt, type: '' }

View File

@@ -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) : [],