feat(ui): intelligent search (#1881)

* feat(ui): start ai summary UI

* feat(ui): add api

* feat(ui): rm console log

* feat(ui): style fix

* feat(ui): some ui changes

* feat(ui): some ui changes

* feat(ui): some text formatting

* feat(ui): ai search stuff

* fix(ui): add int search ui

* feat(ui): map llm response to OR filters

* fix(ui): remove log

* fix
This commit is contained in:
Delirium 2024-03-18 11:12:01 +01:00 committed by GitHub
parent c0f4a99545
commit 39f8602c76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 366 additions and 26 deletions

View file

@ -1,5 +1,6 @@
import React from 'react';
import SessionSearchField from 'Shared/SessionSearchField';
import AiSessionSearchField from 'Shared/SessionSearchField/AiSessionSearchField';
import SavedSearch from 'Shared/SavedSearch';
import { Button } from 'UI';
import { connect } from 'react-redux';
@ -17,10 +18,14 @@ const MainSearchBar = (props: Props) => {
const hasFilters = appliedFilter && appliedFilter.filters && appliedFilter.filters.size > 0;
const hasSavedSearch = props.savedSearch && props.savedSearch.exists();
const hasSearch = hasFilters || hasSavedSearch;
// @ts-ignore
const originStr = window.env.ORIGIN || window.location.origin;
const isSaas = /app\.openreplay\.com/.test(originStr);
return (
<div className="flex items-center">
<div style={{ width: '60%', marginRight: '10px' }}>
<SessionSearchField />
{isSaas ? <AiSessionSearchField /> : <SessionSearchField />}
</div>
<div className="flex items-center gap-2" style={{ width: '40%' }}>
<TagList />
@ -37,6 +42,7 @@ const MainSearchBar = (props: Props) => {
</div>
);
};
export default connect(
(state: any) => ({
appliedFilter: state.getIn(['search', 'instance']),

View file

@ -1,11 +1,12 @@
import React, { useEffect } from 'react';
import AnimatedSVG, { ICONS } from "Shared/AnimatedSVG/AnimatedSVG";
import FilterList from 'Shared/Filters/FilterList';
import FilterSelection from 'Shared/Filters/FilterSelection';
import SaveFilterButton from 'Shared/SaveFilterButton';
import { connect } from 'react-redux';
import { FilterKey } from 'Types/filter/filterType';
import { addOptionsToFilter } from 'Types/filter/newFilter';
import { Button } from 'UI';
import { Button, Loader } from 'UI';
import { edit, addFilter, fetchSessions, updateFilter } from 'Duck/search';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
@ -27,7 +28,7 @@ interface Props {
}
function SessionSearch(props: Props) {
const { tagWatchStore } = useStore();
const { tagWatchStore, aiFiltersStore } = useStore();
const { appliedFilter, saveRequestPayloads = false, metaLoading = false } = props;
const hasEvents = appliedFilter.filters.filter((i: any) => i.isEvent).size > 0;
const hasFilters = appliedFilter.filters.filter((i: any) => !i.isEvent).size > 0;
@ -43,7 +44,7 @@ function SessionSearch(props: Props) {
FilterKey.TAGGED_ELEMENT,
tags.map((tag) => ({
label: tag.name,
value: tag.tagId.toString()
value: tag.tagId.toString(),
}))
);
props.refreshFilterOptions();
@ -96,32 +97,43 @@ function SessionSearch(props: Props) {
debounceFetch();
};
const showPanel = hasEvents || hasFilters || aiFiltersStore.isLoading;
return !metaLoading ? (
<>
{hasEvents || hasFilters ? (
{showPanel ? (
<div className="border bg-white rounded mt-4">
<div className="p-5">
<FilterList
filter={appliedFilter}
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
saveRequestPayloads={saveRequestPayloads}
/>
{aiFiltersStore.isLoading ? (
<div className={'font-semibold flex items-center gap-2'}>
<AnimatedSVG name={ICONS.LOADER} size={18} />
<span>Translating your query into search steps...</span>
</div>
) : null}
{hasEvents || hasFilters ? (
<FilterList
filter={appliedFilter}
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
saveRequestPayloads={saveRequestPayloads}
/>
) : null}
</div>
<div className="border-t px-5 py-1 flex items-center -mx-2">
<div>
<FilterSelection filter={undefined} onFilterClick={onAddFilter}>
<Button variant="text-primary" className="mr-2" icon="plus">
ADD STEP
</Button>
</FilterSelection>
{hasEvents || hasFilters ? (
<div className="border-t px-5 py-1 flex items-center -mx-2">
<div>
<FilterSelection filter={undefined} onFilterClick={onAddFilter}>
<Button variant="text-primary" className="mr-2" icon="plus">
ADD STEP
</Button>
</FilterSelection>
</div>
<div className="ml-auto flex items-center">
<SaveFilterButton />
</div>
</div>
<div className="ml-auto flex items-center">
<SaveFilterButton />
</div>
</div>
) : null}
</div>
) : (
<></>

View file

@ -0,0 +1,190 @@
import React, { useState } from 'react';
import { connect } from 'react-redux';
import { Input, Icon } from 'UI';
import FilterModal from 'Shared/Filters/FilterModal';
import { debounce } from 'App/utils';
import { assist as assistRoute, isRoute } from 'App/routes';
import { addFilterByKeyAndValue, fetchFilterSearch, edit } from 'Duck/search';
import {
addFilterByKeyAndValue as liveAddFilterByKeyAndValue,
fetchFilterSearch as liveFetchFilterSearch,
} from 'Duck/liveSearch';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import { Segmented } from 'antd';
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
const ASSIST_ROUTE = assistRoute();
interface Props {
fetchFilterSearch: (query: any) => void;
addFilterByKeyAndValue: (key: string, value: string) => void;
liveAddFilterByKeyAndValue: (key: string, value: string) => void;
liveFetchFilterSearch: any;
edit: typeof edit;
}
function SessionSearchField(props: Props) {
const isLive =
isRoute(ASSIST_ROUTE, window.location.pathname) ||
window.location.pathname.includes('multiview');
const debounceFetchFilterSearch = React.useCallback(
debounce(isLive ? props.liveFetchFilterSearch : props.fetchFilterSearch, 1000),
[]
);
const [showModal, setShowModal] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const onSearchChange = ({ target: { value } }: any) => {
setSearchQuery(value);
debounceFetchFilterSearch({ q: value });
};
const onAddFilter = (filter: any) => {
isLive
? props.liveAddFilterByKeyAndValue(filter.key, filter.value)
: props.addFilterByKeyAndValue(filter.key, filter.value);
};
const onFocus = () => {
setShowModal(true);
};
const onBlur = () => {
setTimeout(() => {
setShowModal(false);
}, 200);
};
return (
<div className="relative w-full">
<Input
onFocus={onFocus}
onBlur={onBlur}
onChange={onSearchChange}
placeholder={'Search sessions using any captured event (click, input, page, error...)'}
id="search"
type="search"
autoComplete="off"
className="text-lg placeholder-lg !border-0 w-full rounded-r-lg focus:!border-0 focus:ring-0"
/>
{showModal && (
<div className="absolute left-0 border shadow rounded bg-white z-50">
<FilterModal
searchQuery={searchQuery}
isMainSearch={true}
onFilterClick={onAddFilter}
isLive={isLive}
/>
</div>
)}
</div>
);
}
const AiSearchField = observer(({ edit }: Props) => {
const { aiFiltersStore } = useStore();
const [searchQuery, setSearchQuery] = useState('');
const debounceAiFetch = React.useCallback(debounce(aiFiltersStore.getSearchFilters, 1000), []);
const onSearchChange = ({ target: { value } }: any) => {
if (value !== '' && value !== searchQuery) {
setSearchQuery(value);
debounceAiFetch(value);
}
};
React.useEffect(() => {
if (aiFiltersStore.filtersSetKey !== 0) {
console.log('updating filters', aiFiltersStore.filters, aiFiltersStore.filtersSetKey);
edit(aiFiltersStore.filters)
}
}, [aiFiltersStore.filters, aiFiltersStore.filtersSetKey])
return (
<div className={'w-full'}>
<Input
onChange={onSearchChange}
placeholder={'E.g., "Sessions with login issues this week"'}
id="search"
type="search"
value={searchQuery}
autoComplete="off"
className="text-lg placeholder-lg !border-0 rounded-r-lg focus:!border-0 focus:ring-0"
/>
</div>
);
})
function AiSessionSearchField(props: Props) {
const [tab, setTab] = useState('search');
const [isFocused, setIsFocused] = useState(false);
const boxStyle = isFocused ? gradientBox : gradientBoxUnfocused;
return (
<OutsideClickDetectingDiv
onClickOutside={() => setIsFocused(false)}
className={'bg-white rounded-lg'}
>
<div style={boxStyle} onClick={() => setIsFocused(true)}>
<Segmented
value={tab}
// className={'bg-figmaColors-divider'}
onChange={(value) => setTab(value as string)}
options={[
{
label: (
<div className={'flex items-center gap-2'}>
<Icon name={'search'} size={16} />
<span>Search</span>
</div>
),
value: 'search',
},
{
label: (
<div className={'flex items-center gap-2'}>
<Icon name={'sparkles'} size={16} />
<span>Ask AI</span>
</div>
),
value: 'ask',
},
]}
/>
{tab === 'ask' ? <AiSearchField {...props} /> : <SessionSearchField {...props} />}
</div>
</OutsideClickDetectingDiv>
);
}
const gradientBox = {
border: 'double 1px transparent',
borderRadius: '6px',
background:
'linear-gradient(#f6f6f6, #f6f6f6), linear-gradient(to right, #394EFF 0%, #3EAAAF 100%)',
backgroundOrigin: 'border-box',
backgroundClip: 'content-box, border-box',
display: 'flex',
gap: '0.25rem',
alignItems: 'center',
width: '100%',
};
const gradientBoxUnfocused = {
borderRadius: '6px',
border: 'double 1px transparent',
background: '#f6f6f6',
display: 'flex',
gap: '0.25rem',
alignItems: 'center',
width: '100%',
};
export default connect(null, {
addFilterByKeyAndValue,
fetchFilterSearch,
liveFetchFilterSearch,
liveAddFilterByKeyAndValue,
edit,
})(observer(AiSessionSearchField));

View file

@ -9,7 +9,9 @@ import {
addFilterByKeyAndValue as liveAddFilterByKeyAndValue,
fetchFilterSearch as liveFetchFilterSearch,
} from 'Duck/liveSearch';
const ASSIST_ROUTE = assistRoute();
import { observer } from 'mobx-react-lite';
interface Props {
fetchFilterSearch: (query: any) => void;
@ -17,6 +19,7 @@ interface Props {
liveAddFilterByKeyAndValue: (key: string, value: string) => void;
liveFetchFilterSearch: any;
}
function SessionSearchField(props: Props) {
const isLive =
isRoute(ASSIST_ROUTE, window.location.pathname) ||
@ -72,4 +75,4 @@ export default connect(null, {
fetchFilterSearch,
liveFetchFilterSearch,
liveAddFilterByKeyAndValue,
})(SessionSearchField);
})(observer(SessionSearchField));

View file

@ -0,0 +1,117 @@
import { makeAutoObservable } from 'mobx';
import { aiService } from 'App/services';
import Filter from 'Types/filter';
import { FilterKey } from 'Types/filter/filterType';
export default class AiFiltersStore {
filters: Record<string, any> = { filters: [] };
filtersSetKey = 0;
isLoading: boolean = false;
constructor() {
makeAutoObservable(this);
}
setFilters = (filters: Record<string, any>): void => {
this.filters = filters;
this.filtersSetKey += 1;
};
getSearchFilters = async (query: string): Promise<any> => {
this.isLoading = true;
try {
const r = await aiService.getSearchFilters(query);
const filterObj = Filter({
filters: r.filters.map((f: Record<string, any>) => {
if (f.key === 'fetch') {
return mapFetch(f);
} else {
return { ...f, value: f.value ?? [] };
}
}),
eventsOrder: r.eventsOrder.toLowerCase(),
});
this.setFilters(filterObj);
return r;
} catch (e) {
console.trace(e);
} finally {
this.isLoading = false;
}
};
}
const defaultFetchFilter = {
value: [],
key: FilterKey.FETCH,
type: FilterKey.FETCH,
operator: 'is',
isEvent: true,
filters: [
{
value: [],
type: 'fetchUrl',
operator: 'is',
filters: [],
},
{
value: ['200'],
type: 'fetchStatusCode',
operator: '>',
filters: [],
},
{
value: [],
type: 'fetchMethod',
operator: 'is',
filters: [],
},
{
value: [],
type: 'fetchDuration',
operator: '=',
filters: [],
},
{
value: [],
type: 'fetchRequestBody',
operator: 'is',
filters: [],
},
{
value: [],
type: 'fetchResponseBody',
operator: 'is',
filters: [],
},
],
};
export function isObject(item: any): boolean {
return item && typeof item === 'object' && !Array.isArray(item);
}
export function mergeDeep(target: Record<string, any>, ...sources: any[]): Record<string, any> {
if (!sources.length) return target;
const source = sources.shift();
if (isObject(target) && isObject(source)) {
for (const key in source) {
if (isObject(source[key])) {
if (!target[key]) Object.assign(target, { [key]: {} });
mergeDeep(target[key], source[key]);
} else {
Object.assign(target, { [key]: source[key] });
}
}
}
return mergeDeep(target, ...sources);
}
const mapFetch = (filter: Record<string, any>): Record<string, any> => {
return mergeDeep(filter, defaultFetchFilter);
};

View file

@ -21,6 +21,8 @@ import FeatureFlagsStore from './featureFlagsStore';
import UxtestingStore from './uxtestingStore';
import TagWatchStore from './tagWatchStore';
import AiSummaryStore from "./aiSummaryStore";
import AiFiltersStore from "./aiFiltersStore";
export class RootStore {
dashboardStore: DashboardStore;
metricStore: MetricStore;
@ -42,6 +44,7 @@ export class RootStore {
uxtestingStore: UxtestingStore;
tagWatchStore: TagWatchStore;
aiSummaryStore: AiSummaryStore;
aiFiltersStore: AiFiltersStore;
constructor() {
this.dashboardStore = new DashboardStore();
@ -64,6 +67,7 @@ export class RootStore {
this.uxtestingStore = new UxtestingStore();
this.tagWatchStore = new TagWatchStore();
this.aiSummaryStore = new AiSummaryStore();
this.aiFiltersStore = new AiFiltersStore();
}
initClient() {

View file

@ -4,11 +4,19 @@ export default class AiService extends BaseService {
/**
* @returns stream of text symbols
* */
async getSummary(sessionId: string) {
async getSummary(sessionId: string): Promise<ReadableStream | null> {
const r = await this.client.post(
`/sessions/${sessionId}/intelligent/summary`,
);
return r.json()
}
async getSearchFilters(query: string): Promise<Record<string, any>> {
const r = await this.client.post('/intelligent/search', {
question: query
})
const { data } = await r.json();
return data
}
}

View file

@ -16,7 +16,7 @@ export default class QueueSender {
private readonly onUnauthorised: () => any,
private readonly onFailure: (reason: string) => any,
private readonly MAX_ATTEMPTS_COUNT = 10,
private readonly ATTEMPT_TIMEOUT = 1000,
private readonly ATTEMPT_TIMEOUT = 250,
private readonly onCompress?: (batch: Uint8Array) => any,
) {
this.ingestURL = ingestBaseURL + INGEST_PATH