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:
parent
c0f4a99545
commit
39f8602c76
8 changed files with 366 additions and 26 deletions
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
<></>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
117
frontend/app/mstore/aiFiltersStore.ts
Normal file
117
frontend/app/mstore/aiFiltersStore.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue