ui: kai ui thing

ui: fixes for picking existing chat, feedback and retry buttons

ui: connect finding, creating threads

ui: more ui tuning for chat window, socket manager

ui: get/delete chats logic, create testing socket

ui: testing

ui: use on click query

ui: minor fixed for chat display, rebase

ui: start kai thing
This commit is contained in:
nick-delirium 2025-04-01 17:12:05 +02:00
parent 4e331b70a4
commit efe16a7065
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
47 changed files with 2655 additions and 909 deletions

View file

@ -32,7 +32,8 @@ const components: any = {
SpotsListPure: lazy(() => import('Components/Spots/SpotsList')),
SpotPure: lazy(() => import('Components/Spots/SpotPlayer')),
ScopeSetup: lazy(() => import('Components/ScopeForm')),
HighlightsPure: lazy(() => import('Components/Highlights/HighlightsList'))
HighlightsPure: lazy(() => import('Components/Highlights/HighlightsList')),
KaiPure: lazy(() => import('Components/Kai/KaiChat')),
};
const enhancedComponents: any = {
@ -52,7 +53,8 @@ const enhancedComponents: any = {
SpotsList: withSiteIdUpdater(components.SpotsListPure),
Spot: components.SpotPure,
ScopeSetup: components.ScopeSetup,
Highlights: withSiteIdUpdater(components.HighlightsPure)
Highlights: withSiteIdUpdater(components.HighlightsPure),
Kai: withSiteIdUpdater(components.KaiPure),
};
const { withSiteId } = routes;
@ -97,6 +99,7 @@ const SPOT_PATH = routes.spot();
const SCOPE_SETUP = routes.scopeSetup();
const HIGHLIGHTS_PATH = routes.highlights();
const KAI_PATH = routes.kai();
function PrivateRoutes() {
const { projectsStore, userStore, integrationsStore, searchStore } = useStore();
@ -270,6 +273,12 @@ function PrivateRoutes() {
path={withSiteId(LIVE_SESSION_PATH, siteIdList)}
component={enhancedComponents.LiveSession}
/>
<Route
exact
strict
path={withSiteId(KAI_PATH, siteIdList)}
component={enhancedComponents.Kai}
/>
{Object.entries(routes.redirects).map(([fr, to]) => (
<Redirect key={fr} exact strict from={fr} to={to} />
))}

View file

@ -60,7 +60,7 @@ export default class APIClient {
private siteIdCheck: (() => { siteId: string | null }) | undefined;
private getJwt: () => string | null = () => null;
public getJwt: () => string | null = () => null;
private onUpdateJwt: (data: { jwt?: string; spotJwt?: string }) => void;
@ -197,7 +197,7 @@ export default class APIClient {
delete init.credentials;
}
const noChalice = path.includes('v1/integrations') || path.includes('/spot') && !path.includes('/login');
const noChalice = path.includes('/kai') || path.includes('v1/integrations') || path.includes('/spot') && !path.includes('/login');
let edp = window.env.API_EDP || window.location.origin + '/api';
if (noChalice && !edp.includes('api.openreplay.com')) {
edp = edp.replace('/api', '');

View file

@ -3,11 +3,12 @@ import LiveSessionList from 'Shared/LiveSessionList';
import LiveSessionSearch from 'Shared/LiveSessionSearch';
import usePageTitle from '@/hooks/usePageTitle';
import AssistSearchActions from './AssistSearchActions';
import { PANEL_SIZES } from 'App/constants/panelSizes'
function AssistView() {
usePageTitle('Co-Browse - OpenReplay');
return (
<div className="w-full mx-auto" style={{ maxWidth: '1360px' }}>
<div className="w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
<AssistSearchActions />
<LiveSessionSearch />
<div className="my-4" />

View file

@ -7,6 +7,7 @@ import { observer } from 'mobx-react-lite';
import RecordingsList from './RecordingsList';
import RecordingsSearch from './RecordingsSearch';
import { useTranslation } from 'react-i18next';
import { PANEL_SIZES } from 'App/constants/panelSizes'
function Recordings() {
const { t } = useTranslation();
@ -24,7 +25,7 @@ function Recordings() {
return (
<div
style={{ maxWidth: '1360px', margin: 'auto' }}
style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}
className="bg-white rounded-lg py-4 border h-screen overflow-y-scroll"
>
<div className="flex items-center mb-4 justify-between px-6">

View file

@ -2,6 +2,7 @@ import React from 'react';
import { withRouter } from 'react-router-dom';
import { Switch, Route, Redirect } from 'react-router';
import { CLIENT_TABS, client as clientRoute } from 'App/routes';
import { PANEL_SIZES } from 'App/constants/panelSizes'
import SessionsListingSettings from 'Components/Client/SessionsListingSettings';
import Modules from 'Components/Client/Modules';
@ -105,7 +106,7 @@ export default class Client extends React.PureComponent {
},
} = this.props;
return (
<div className="w-full mx-auto mb-8" style={{ maxWidth: '1360px' }}>
<div className="w-full mx-auto mb-8" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
{activeTab && this.renderActiveTab()}
</div>
);

View file

@ -10,6 +10,7 @@ import { useStore } from 'App/mstore';
import AlertsList from './AlertsList';
import AlertsSearch from './AlertsSearch';
import { useTranslation } from 'react-i18next';
import { PANEL_SIZES } from 'App/constants/panelSizes'
interface IAlertsView {
siteId: string;
@ -30,7 +31,7 @@ function AlertsView({ siteId }: IAlertsView) {
}, [history]);
return (
<div
style={{ maxWidth: '1360px', margin: 'auto' }}
style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}
className="bg-white rounded-lg shadow-sm py-4 border"
>
<div className="flex items-center mb-4 justify-between px-6">

View file

@ -16,6 +16,7 @@ import NotifyHooks from './AlertForm/NotifyHooks';
import AlertListItem from './AlertListItem';
import Condition from './AlertForm/Condition';
import { useTranslation } from 'react-i18next';
import { PANEL_SIZES } from 'App/constants/panelSizes'
function Circle({ text }: { text: string }) {
return (
@ -200,7 +201,7 @@ function NewAlert(props: IProps) {
const isThreshold = instance.detectionMethod === 'threshold';
return (
<div style={{ maxWidth: '1360px', margin: 'auto' }}>
<div style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}>
<Breadcrumb
items={[
{

View file

@ -2,11 +2,12 @@ import React from 'react';
import withPageTitle from 'HOCs/withPageTitle';
import DashboardList from './DashboardList';
import Header from './Header';
import { PANEL_SIZES } from 'App/constants/panelSizes'
function DashboardsView({ history, siteId }: { history: any; siteId: string }) {
function DashboardsView() {
return (
<div
style={{ maxWidth: '1360px', margin: 'auto' }}
style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}
className="bg-white rounded-lg py-4 border shadow-sm"
>
<Header />

View file

@ -8,6 +8,7 @@ import { dashboardMetricCreate, withSiteId } from 'App/routes';
import DashboardForm from '../DashboardForm';
import DashboardMetricSelection from '../DashboardMetricSelection';
import { useTranslation } from 'react-i18next';
import { PANEL_SIZES } from 'App/constants/panelSizes'
interface Props extends RouteComponentProps {
history: any;
@ -57,7 +58,7 @@ function DashboardModal(props: Props) {
backgroundColor: '#FAFAFA',
zIndex: 999,
width: '100%',
maxWidth: '1360px',
maxWidth: PANEL_SIZES.maxWidth,
}}
>
<div className="mb-6 flex items-end justify-between">

View file

@ -14,6 +14,7 @@ import DashboardHeader from '../DashboardHeader';
import DashboardModal from '../DashboardModal';
import DashboardWidgetGrid from '../DashboardWidgetGrid';
import AiQuery from './AiQuery';
import { PANEL_SIZES } from 'App/constants/panelSizes'
interface IProps {
siteId: string;
@ -103,7 +104,7 @@ function DashboardView(props: Props) {
return (
<Loader loading={loading}>
<div
style={{ maxWidth: '1360px', margin: 'auto' }}
style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}
className="rounded-lg shadow-sm overflow-hidden bg-white border"
>
{/* @ts-ignore */}

View file

@ -3,6 +3,7 @@ import withPageTitle from 'HOCs/withPageTitle';
import { observer } from 'mobx-react-lite';
import MetricsList from '../MetricsList';
import MetricViewHeader from '../MetricViewHeader';
import { PANEL_SIZES } from 'App/constants/panelSizes'
interface Props {
siteId: string;
@ -10,7 +11,7 @@ interface Props {
function MetricsView({ siteId }: Props) {
return (
<div
style={{ maxWidth: '1360px', margin: 'auto' }}
style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}
className="bg-white rounded-lg shadow-sm pt-4 border"
>
<MetricViewHeader siteId={siteId} />

View file

@ -31,6 +31,7 @@ import CardUserList from '../CardUserList/CardUserList';
import WidgetSessions from '../WidgetSessions';
import WidgetPreview from '../WidgetPreview';
import { useTranslation } from 'react-i18next';
import { PANEL_SIZES } from 'App/constants/panelSizes';
interface Props {
history: any;
@ -183,7 +184,7 @@ function WidgetView({
: 'You have unsaved changes. Are you sure you want to leave?'
}
/>
<div style={{ maxWidth: '1360px', margin: 'auto' }}>
<div style={{ maxWidth: PANEL_SIZES.maxWidth, margin: 'auto' }}>
<Breadcrumb
items={[
{

View file

@ -10,6 +10,7 @@ import Select from 'Shared/Select';
import usePageTitle from '@/hooks/usePageTitle';
import FFlagItem from './FFlagItem';
import { useTranslation } from 'react-i18next';
import { PANEL_SIZES } from 'App/constants/panelSizes';
function FFlagsList({ siteId }: { siteId: string }) {
const { t } = useTranslation();
@ -24,7 +25,7 @@ function FFlagsList({ siteId }: { siteId: string }) {
return (
<div
className="mb-5 w-full mx-auto bg-white rounded pb-10 pt-4 widget-wrapper"
style={{ maxWidth: '1360px' }}
style={{ maxWidth: PANEL_SIZES.maxWidth }}
>
<FFlagsListHeader siteId={siteId} />
<div className="border-y px-3 py-2 mt-2 flex items-center w-full justify-end gap-4">

View file

@ -10,6 +10,7 @@ import Multivariant from 'Components/FFlags/NewFFlag/Multivariant';
import { toast } from 'react-toastify';
import RolloutCondition from 'Shared/ConditionSet';
import { useTranslation } from 'react-i18next';
import { PANEL_SIZES } from "App/constants/panelSizes";
function FlagView({ siteId, fflagId }: { siteId: string; fflagId: string }) {
const { t } = useTranslation();
@ -52,7 +53,7 @@ function FlagView({ siteId, fflagId }: { siteId: string; fflagId: string }) {
};
return (
<div className="w-full mx-auto mb-4" style={{ maxWidth: '1360px' }}>
<div className="w-full mx-auto mb-4" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
<Breadcrumb
items={[
{ label: t('Feature Flags'), to: withSiteId(fflags(), siteId) },

View file

@ -17,6 +17,7 @@ import Header from './Header';
import Multivariant from './Multivariant';
import { Payload } from './Helpers';
import { useTranslation } from 'react-i18next';
import { PANEL_SIZES } from 'App/constants/panelSizes';
function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
const { t } = useTranslation();
@ -40,7 +41,7 @@ function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
if (featureFlagsStore.isLoading) return <Loader loading />;
if (!current) {
return (
<div className="w-full mx-auto mb-4" style={{ maxWidth: '1360px' }}>
<div className="w-full mx-auto mb-4" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
<Breadcrumb
items={[
{ label: 'Feature Flags', to: withSiteId(fflags(), siteId) },
@ -90,7 +91,7 @@ function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
const showDescription = Boolean(current.description?.length);
return (
<div className="w-full mx-auto mb-4" style={{ maxWidth: '1360px' }}>
<div className="w-full mx-auto mb-4" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
<Prompt
when={current.hasChanged}
message={() =>

View file

@ -0,0 +1,174 @@
import React from 'react';
import { useModal } from 'App/components/Modal';
import { MessagesSquare, Trash } from 'lucide-react';
import ChatHeader from './components/ChatHeader';
import { PANEL_SIZES } from 'App/constants/panelSizes';
import ChatLog from './components/ChatLog';
import IntroSection from './components/IntroSection';
import { useQuery } from '@tanstack/react-query';
import { aiService } from 'App/services';
import { toast } from 'react-toastify';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { useHistory, useLocation } from 'react-router-dom'
function KaiChat() {
const { userStore, projectsStore } = useStore();
const history = useHistory();
const [chatTitle, setTitle] = React.useState<string | null>(null);
const userId = userStore.account.id;
const userLetter = userStore.account.name[0].toUpperCase();
const { activeSiteId } = projectsStore;
const [section, setSection] = React.useState<'intro' | 'chat'>('intro');
const [threadId, setThreadId] = React.useState<string | null>(null);
const [initialMsg, setInitialMsg] = React.useState<string | null>(null);
const { showModal, hideModal } = useModal();
const location = useLocation();
const params = new URLSearchParams(location.search);
const threadIdFromUrl = params.get('threadId');
const openChats = () => {
showModal(
<ChatsModal onSelect={(threadId: string, title: string) => {
setTitle(title);
setThreadId(threadId)
hideModal();
}} />,
{ right: true, width: 300 },
);
};
React.useEffect(() => {
if (threadIdFromUrl) {
setThreadId(threadIdFromUrl);
setSection('chat');
}
}, [threadIdFromUrl])
React.useEffect(() => {
if (threadId) {
setSection('chat');
history.replace({ search: `?threadId=${threadId}` });
} else {
setTitle(null);
history.replace({ search: '' });
}
}, [threadId]);
if (!userId || !activeSiteId) return null;
const canGoBack = section !== 'intro';
const goBack = canGoBack ? () => {
if (section === 'chat') {
setThreadId(null);
setSection('intro')
}
} : undefined;
const onCreate = async (firstMsg?: string) => {
//const settings = { projectId: projectId ?? 2325, userId: userId ?? 65 };
const settings = { projectId: '2325', userId: '0' };
if (firstMsg) {
setInitialMsg(firstMsg);
}
const newThread = await aiService.createKaiChat(settings.projectId, settings.userId)
if (newThread) {
setThreadId(newThread.toString());
setSection('chat');
} else {
toast.error("Something wen't wrong. Please try again later.");
}
}
return (
<div className="w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
<div className={'w-full rounded-lg overflow-hidden border shadow'}>
<ChatHeader chatTitle={chatTitle} openChats={openChats} goBack={goBack} />
<div
className={
'w-full bg-active-blue flex flex-col items-center justify-center py-4 relative'
}
style={{
height: '70svh',
background:
'radial-gradient(50% 50% at 50% 50%, rgba(255, 255, 255, 0.50) 0%, rgba(248, 255, 254, 0.50) 46%, rgba(243, 241, 255, 0.50) 100%)',
}}
>
{section === 'intro' ? (
<IntroSection onAsk={onCreate} />
) : (
<ChatLog
threadId={threadId}
projectId={activeSiteId}
userId={userId}
userLetter={userLetter}
onTitleChange={setTitle}
initialMsg={initialMsg}
setInitialMsg={setInitialMsg}
/>
)}
</div>
</div>
</div>
);
}
function ChatsModal({ onSelect }: { onSelect: (threadId: string, title: string) => void }) {
const userId = '0';
const projectId = '2325';
const {
data = [],
isPending,
refetch,
} = useQuery({
queryKey: ['kai', 'chats'],
queryFn: () => aiService.getKaiChats(userId, projectId),
staleTime: 1000 * 60,
cacheTime: 1000 * 60 * 5,
});
const onDelete = async (id: string) => {
try {
await aiService.deleteKaiChat(projectId, userId, id);
} catch (e) {
toast.error("Something wen't wrong. Please try again later.");
}
refetch();
};
return (
<div className={'h-screen w-full flex flex-col gap-2 p-4'}>
<div className={'flex items-center font-semibold text-lg gap-2'}>
<MessagesSquare size={16} />
<span>Chats</span>
</div>
{isPending ? (
<div className="animate-pulse text-disabled-text">Loading chats...</div>
) : (
<div className="flex flex-col overflow-y-auto -mx-4 px-4">
{data.map((chat) => (
<div key={chat.thread_id} className="flex items-center relative group min-h-8">
<div
style={{ width: 270 - 28 - 4 }}
className="rounded-l pl-2 h-full w-full hover:bg-active-blue flex items-center"
>
<div
onClick={() => onSelect(chat.thread_id, chat.title)}
className="cursor-pointer hover:underline truncate"
>
{chat.title}
</div>
</div>
<div
onClick={() => onDelete(chat.thread_id)}
className="cursor-pointer opacity-0 group-hover:opacity-100 rounded-r h-full px-2 flex items-center group-hover:bg-active-blue"
>
<Trash size={14} className="text-disabled-text" />
</div>
</div>
))}
</div>
)}
</div>
);
}
export default observer(KaiChat);

View file

@ -0,0 +1,75 @@
import io from 'socket.io-client';
export class ChatManager {
socket: ReturnType<typeof io>;
threadId: string | null = null;
constructor({ projectId, userId, threadId }: { projectId: string; userId: string; threadId: string }) {
this.threadId = threadId;
const socket = io(`localhost:8700/kai/chat`, {
transports: ['websocket'],
autoConnect: true,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
withCredentials: true,
multiplex: true,
query: {
user_id: userId,
token: window.env.KAI_TESTING,
project_id: projectId,
},
});
socket.on('connect', () => {
console.log('Connected to server');
});
socket.on('disconnect', () => {
console.log('Disconnected from server');
});
socket.onAny((e) => console.log('event', e));
this.socket = socket;
}
sendMesage = (message: string) => {
this.socket.emit(
'message',
JSON.stringify({
message,
threadId: this.threadId,
}),
);
};
setOnMsgHook = ({
msgCallback,
titleCallback,
}: {
msgCallback: (msg: BotChunk) => void;
titleCallback: (title: string) => void;
}) => {
this.socket.on('chunk', (msg: BotChunk) => {
console.log('Received message:', msg);
msgCallback(msg);
});
this.socket.on('title', (msg: { content: string }) => {
console.log('Received title:', msg);
titleCallback(msg.content);
});
};
disconnect = () => {
this.socket.disconnect();
};
}
export interface BotChunk {
stage: 'chart' | 'final' | 'title';
content: string;
data?: any[];
}
export interface Message {
text: string;
isUser: boolean;
}

View file

@ -0,0 +1,54 @@
import React from 'react';
import { Icon } from 'UI';
import { MessagesSquare, ArrowLeft } from 'lucide-react';
function ChatHeader({
openChats = () => {},
goBack,
chatTitle,
}: {
goBack?: () => void;
openChats?: () => void;
chatTitle: string | null;
}) {
return (
<div
className={
'px-4 py-2 flex items-center bg-white border-b border-b-gray-lighter'
}
>
<div className={'flex-1'}>
{goBack ? (
<div
className={'flex items-center gap-2 font-semibold cursor-pointer'}
onClick={goBack}
>
<ArrowLeft size={14} />
<div>Back</div>
</div>
) : null}
</div>
<div className={'flex items-center gap-2 mx-auto max-w-[80%]'}>
{chatTitle ? (
<div className="font-semibold text-xl whitespace-nowrap truncate">{chatTitle}</div>
) : (
<>
<Icon name={'kai_colored'} size={18} />
<div className={'font-semibold text-xl'}>Kai</div>
</>
)}
</div>
<div
className={
'font-semibold cursor-pointer flex items-center gap-2 flex-1 justify-end'
}
onClick={openChats}
>
<MessagesSquare size={14} />
<div>Chats</div>
</div>
</div>
);
}
export default ChatHeader;

View file

@ -0,0 +1,37 @@
import React from 'react'
import { Button, Input } from "antd";
import { SendHorizonal } from "lucide-react";
function ChatInput({ isLoading, onSubmit }: { isLoading?: boolean, onSubmit: (str: string) => void }) {
const [inputValue, setInputValue] = React.useState<string>('');
const submit = () => {
onSubmit(inputValue)
setInputValue('')
}
return (
<Input
placeholder={'Ask anything about your product and users...'}
size={'large'}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && inputValue) {
submit()
}
}}
suffix={
<Button
loading={isLoading}
onClick={submit}
icon={<SendHorizonal size={16} />}
type={'text'}
size={'small'}
shape={'circle'}
/>
}
/>
)
}
export default ChatInput

View file

@ -0,0 +1,149 @@
import React from 'react';
import ChatInput from './ChatInput';
import { ChatMsg, ChatNotice } from './ChatMsg';
import { ChatManager } from '../SocketManager';
import type { BotChunk, Message } from '../SocketManager';
import { aiService } from 'App/services';
import { toast } from 'react-toastify';
import { Loader } from 'UI';
function ChatLog({
projectId,
userId,
threadId,
userLetter,
onTitleChange,
initialMsg,
setInitialMsg,
}: {
projectId: string;
userId: string;
threadId: any;
userLetter: string;
onTitleChange: (title: string | null) => void;
initialMsg: string | null;
setInitialMsg: (msg: string | null) => void;
}) {
const chatManager = React.useRef<ChatManager | null>(null);
const chatRef = React.useRef<HTMLDivElement>(null);
const [messages, setMessages] = React.useState<Message[]>(
initialMsg ? [{ text: initialMsg, isUser: true }] : [],
);
const [processingStage, setProcessing] = React.useState<BotChunk | null>(
null,
);
const [isLoading, setLoading] = React.useState(false);
React.useEffect(() => {
console.log(threadId, initialMsg);
if (threadId && !initialMsg) {
setLoading(true);
//const settings = { projectId: projectId ?? 2325, userId: userId ?? 65 };
const settings = { projectId: '2325', userId: '0' };
aiService
.getKaiChat(settings.projectId, settings.userId, threadId)
.then((res) => {
if (res && res.length) {
setMessages(
res.map((m) => {
const isUser = m.role === 'human';
return {
text: m.content,
isUser: isUser,
};
}),
);
}
})
.catch((e) => {
console.error(e);
toast.error("Couldn't load chat history. Please try again later.");
})
.finally(() => {
setLoading(false);
});
chatManager.current = new ChatManager(settings);
chatManager.current.setOnMsgHook({
msgCallback: (msg) => {
if (msg.stage === 'chart') {
setProcessing(msg);
}
if (msg.stage === 'final') {
setMessages((prev) => [
...prev,
{
text: msg.content,
isUser: false,
userName: 'Kai',
},
]);
setProcessing(null);
}
},
titleCallback: (title) => onTitleChange(title),
});
}
return () => {
chatManager.current?.disconnect();
setInitialMsg(null);
};
}, [threadId]);
React.useEffect(() => {
if (initialMsg) {
chatManager.current?.sendMesage(initialMsg);
}
}, [initialMsg]);
const onSubmit = (text: string) => {
chatManager.current?.sendMesage(text);
setMessages((prev) => [
...prev,
{
text,
isUser: true,
},
]);
};
React.useEffect(() => {
chatRef.current?.scrollTo({
top: chatRef.current.scrollHeight,
behavior: 'smooth',
});
}, [messages.length]);
const newChat = messages.length === 1 && processingStage === null;
return (
<Loader loading={isLoading} className={'w-full h-full'}>
<div
ref={chatRef}
className={
'overflow-y-auto relative flex flex-col items-center justify-between w-full h-full'
}
>
<div className={'flex flex-col gap-4 w-2/3 min-h-max'}>
{messages.map((msg, index) => (
<ChatMsg
key={index}
text={msg.text}
isUser={msg.isUser}
userName={userLetter}
/>
))}
{processingStage ? (
<ChatNotice content={processingStage.content} />
) : null}
{newChat ? (
<ChatNotice content={'Processing your question...'} />
) : null}
</div>
<div className={'sticky bottom-0 pt-6 w-2/3'}>
<ChatInput onSubmit={onSubmit} />
</div>
</div>
</Loader>
);
}
export default ChatLog;

View file

@ -0,0 +1,94 @@
import React from 'react';
import { Icon } from 'UI';
import cn from 'classnames';
import Markdown from 'react-markdown';
import { Loader, ThumbsUp, ThumbsDown, ListRestart } from 'lucide-react';
import { toast } from 'react-toastify';
export function ChatMsg({
text,
isUser,
userName,
}: {
text: string;
isUser: boolean;
userName?: string;
}) {
const onClick = () => {
toast.info('I do nothing!');
};
return (
<div
className={cn(
'flex items-start gap-2',
isUser ? 'flex-row-reverse' : 'flex-row',
)}
>
{isUser ? (
<div
className={
'rounded-full bg-main text-white min-w-8 min-h-8 flex items-center justify-center sticky top-0'
}
>
<span className={'font-semibold'}>{userName}</span>
</div>
) : (
<div
className={
'rounded-full bg-white shadow min-w-8 min-h-8 flex items-center justify-center sticky top-0'
}
>
<Icon name={'kai_colored'} size={18} />
</div>
)}
<div className={'mt-1'}>
<Markdown>{text}</Markdown>
{isUser ? null : (
<div className={'flex items-center gap-2'}>
<IconButton onClick={onClick}>
<ThumbsUp size={16} />
</IconButton>
<IconButton onClick={onClick}>
<ThumbsDown size={16} />
</IconButton>
<div
onClick={onClick}
className={
'flex items-center gap-2 px-2 rounded-lg border border-gray-medium text-sm cursor-pointer hover:border-main hover:text-main'
}
>
<ListRestart size={16} />
<div>Retry</div>
</div>
</div>
)}
</div>
</div>
);
}
function IconButton({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) {
return (
<div className={'cursor-pointer hover:text-main'} onClick={onClick}>
{children}
</div>
);
}
export function ChatNotice({ content }: { content: string }) {
return (
<div className="flex items-center gap-2 p-2 rounded-lg bg-gray-lightest border-gray-light w-fit">
<div className={'animate-spin'}>
<Loader size={14} />
</div>
<div className={'animate-pulse text-disabled-text'}>{content}</div>
</div>
);
}

View file

@ -0,0 +1,32 @@
import React from 'react';
import { Lightbulb, MoveRight } from 'lucide-react';
function Ideas({ onClick }: { onClick: (query: string) => void }) {
return (
<>
<div className={'flex items-center gap-2 mb-1 text-gray-dark'}>
<Lightbulb size={16} />
<b>Ideas:</b>
</div>
<IdeaItem onClick={onClick} title={'Top user journeys'} />
<IdeaItem onClick={onClick} title={'Where do users drop off'} />
<IdeaItem onClick={onClick} title={'Failed network requests today'} />
</>
);
}
function IdeaItem({ title, onClick }: { title: string, onClick: (query: string) => void }) {
return (
<div
onClick={() => onClick(title)}
className={
'flex items-center gap-2 cursor-pointer text-gray-dark hover:text-black'
}
>
<MoveRight size={16} />
<span>{title}</span>
</div>
);
}
export default Ideas;

View file

@ -0,0 +1,27 @@
import React from 'react';
import ChatInput from './ChatInput';
import Ideas from './Ideas';
function IntroSection({ onAsk }: { onAsk: (query: string) => void }) {
const isLoading = false;
return (
<>
<div className={'text-disabled-text text-xl absolute top-4'}>
Kai is your AI assistant, delivering smart insights in response to your
queries.
</div>
<div className={'relative w-2/3'} style={{ height: 44 }}>
{/*<GradientBorderInput placeholder={'Ask anything about your product and users...'} onButtonClick={() => null} />*/}
<ChatInput isLoading={isLoading} onSubmit={onAsk} />
<div className={'absolute top-full flex flex-col gap-2 mt-4'}>
<Ideas onClick={(query) => onAsk(query)} />
</div>
</div>
<div className={'text-disabled-text absolute bottom-4'}>
OpenReplay AI can make mistakes. Verify its outputs.
</div>
</>
);
}
export default IntroSection;

View file

@ -9,6 +9,7 @@ import ManageUsersTab from './components/ManageUsersTab';
import SideMenu from './components/SideMenu';
import { useTranslation } from 'react-i18next';
import { Smartphone, AppWindow } from 'lucide-react';
import { PANEL_SIZES } from 'App/constants/panelSizes';
interface Props {
match: {
@ -66,7 +67,7 @@ function Onboarding(props: Props) {
<div className="w-full">
<div
className="bg-white w-full rounded-lg mx-auto mb-8 border"
style={{ maxWidth: '1360px' }}
style={{ maxWidth: PANEL_SIZES.maxWidth }}
>
<Switch>
<Route exact strict path={route(OB_TABS.INSTALLING)}>

View file

@ -18,6 +18,7 @@ import FlagView from 'Components/FFlags/FlagView/FlagView';
import { observer } from 'mobx-react-lite';
import { useStore } from '@/mstore';
import Bookmarks from 'Shared/SessionsTabOverview/components/Bookmarks/Bookmarks';
import { PANEL_SIZES } from 'App/constants/panelSizes';
// @ts-ignore
interface IProps extends RouteComponentProps {
@ -42,12 +43,12 @@ function Overview({ match: { params } }: IProps) {
return (
<Switch>
<Route exact strict path={withSiteId(sessions(), siteId)}>
<div className="mb-5 w-full mx-auto" style={{ maxWidth: '1360px' }}>
<div className="mb-5 w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
<SessionsTabOverview />
</div>
</Route>
<Route exact strict path={withSiteId(bookmarks(), siteId)}>
<div className="mb-5 w-full mx-auto" style={{ maxWidth: '1360px' }}>
<div className="mb-5 w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
<Bookmarks />
</div>
</Route>

View file

@ -20,7 +20,7 @@ import { toast } from 'react-toastify';
import StepsModal from './StepsModal';
import SidePanel from './SidePanel';
import { useTranslation } from 'react-i18next';
import { PANEL_SIZES } from 'App/constants/panelSizes';
const menuItems = [
{
key: '1',
@ -160,7 +160,7 @@ function TestEdit() {
const isStartingPointValid = isValidUrl(uxtestingStore.instance.startingPath);
return (
<div className="w-full mx-auto" style={{ maxWidth: '1360px' }}>
<div className="w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
<Breadcrumb
items={[
{

View file

@ -43,6 +43,7 @@ import { toast } from 'react-toastify';
import ResponsesOverview from './ResponsesOverview';
import { useTranslation } from 'react-i18next';
import { TFunction } from 'i18next';
import { PANEL_SIZES } from 'App/constants/panelSizes';
function StatusItem({
iconName,
@ -155,7 +156,7 @@ function TestOverview() {
return (
<div
className="w-full mx-auto"
style={{ maxWidth: '1360px' }}
style={{ maxWidth: PANEL_SIZES.maxWidth }}
id="pdf-anchor"
>
<Breadcrumb

View file

@ -17,6 +17,7 @@ import {
} from 'App/routes';
import withPageTitle from 'HOCs/withPageTitle';
import { useTranslation } from 'react-i18next';
import { PANEL_SIZES } from 'App/constants/panelSizes';
const { Search } = Input;
@ -76,7 +77,7 @@ function TestsTable() {
};
return (
<div className="w-full mx-auto" style={{ maxWidth: '1360px' }}>
<div className="w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
<Modal
title={t('Create Usability Test')}
open={isModalVisible}

View file

@ -2,12 +2,13 @@ import React from 'react';
import NotesList from './NoteList';
import NoteTags from './NoteTags';
import { useTranslation } from 'react-i18next';
import { PANEL_SIZES } from 'App/constants/panelSizes';
function NotesRoute() {
const { t } = useTranslation();
return (
<div className="mb-5 w-full mx-auto" style={{ maxWidth: '1360px' }}>
<div className="mb-5 w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
<div className="widget-wrapper">
<div className="flex items-center px-4 py-2 justify-between w-full border-b">
<div className="flex items-center justify-end w-full">

View file

@ -353,6 +353,8 @@ export { default as Integrations_teams } from './integrations_teams';
export { default as Integrations_vuejs } from './integrations_vuejs';
export { default as Integrations_zustand } from './integrations_zustand';
export { default as Journal_code } from './journal_code';
export { default as Kai } from './kai';
export { default as Kai_colored } from './kai_colored';
export { default as Key } from './key';
export { default as Keyboard } from './keyboard';
export { default as Layers_half } from './layers_half';

View file

@ -0,0 +1,18 @@
/* Auto-generated, do not edit */
import React from 'react';
interface Props {
size?: number | string;
width?: number | string;
height?: number | string;
fill?: string;
}
function Kai(props: Props) {
const { size = 14, width = size, height = size, fill = '' } = props;
return (
<svg viewBox="0 0 14 15" fill="none" width={ `${ width }px` } height={ `${ height }px` } ><g stroke="#394EFF" strokeLinecap="round" strokeLinejoin="round"><path d="m7 1.862-1.115 3.39a1.167 1.167 0 0 1-.744.744L1.75 7.112l3.39 1.115a1.167 1.167 0 0 1 .745.744L7 12.36l1.115-3.39a1.167 1.167 0 0 1 .744-.744l3.391-1.115-3.39-1.116a1.167 1.167 0 0 1-.745-.743L7 1.862Z" fill="#fff"/><path d="M2.917 1.862v2.333M11.083 10.028v2.334M1.75 3.028h2.333M9.917 11.195h2.333"/></g></svg>
);
}
export default Kai;

View file

@ -0,0 +1,64 @@
/* Auto-generated, do not edit */
import React from 'react';
interface Props {
size?: number | string;
width?: number | string;
height?: number | string;
fill?: string;
}
function Kai_colored(props: Props) {
const { size = 14, width = size, height = size, fill = '' } = props;
return (
<svg viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon &#60;- change size here" clipPath="url(#clip0_142_2721)">
<path id="Vector" d="M11.5 2.86172L9.74733 8.19031C9.65764 8.46302 9.50514 8.71086 9.30214 8.91387C9.09914 9.11687 8.8513 9.26936 8.57858 9.35906L3.25 11.1117L8.57858 12.8644C8.8513 12.9541 9.09914 13.1066 9.30214 13.3096C9.50514 13.5126 9.65764 13.7604 9.74733 14.0331L11.5 19.3617L13.2527 14.0331C13.3424 13.7604 13.4949 13.5126 13.6979 13.3096C13.9009 13.1066 14.1487 12.9541 14.4214 12.8644L19.75 11.1117L14.4214 9.35906C14.1487 9.26936 13.9009 9.11687 13.6979 8.91387C13.4949 8.71086 13.3424 8.46302 13.2527 8.19031L11.5 2.86172Z" fill="white" stroke="url(#paint0_linear_142_2721)" strokeWidth="2"/>
<g id="Vector_2">
<path d="M5.08331 2.86172V6.52839V2.86172Z" fill="white"/>
<path d="M5.08331 2.86172V6.52839" stroke="url(#paint1_linear_142_2721)" strokeWidth="2"/>
</g>
<g id="Vector_3">
<path d="M17.9167 15.695V19.3617V15.695Z" fill="white"/>
<path d="M17.9167 15.695V19.3617" stroke="url(#paint2_linear_142_2721)" strokeWidth="2"/>
</g>
<g id="Vector_4">
<path d="M3.25 4.69504H6.91667H3.25Z" fill="white"/>
<path d="M3.25 4.69504H6.91667" stroke="url(#paint3_linear_142_2721)" strokeWidth="2"/>
</g>
<g id="Vector_5">
<path d="M16.0833 17.5284H19.75H16.0833Z" fill="white"/>
<path d="M16.0833 17.5284H19.75" stroke="url(#paint4_linear_142_2721)" strokeWidth="2"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_142_2721" x1="3.25" y1="11.1117" x2="19.75" y2="11.1117" gradientUnits="userSpaceOnUse">
<stop stopColor="#6F5CEC"/>
<stop offset="1" stopColor="#3CC377"/>
</linearGradient>
<linearGradient id="paint1_linear_142_2721" x1="5.08331" y1="4.69506" x2="6.08331" y2="4.69506" gradientUnits="userSpaceOnUse">
<stop stopColor="#6F5CEC"/>
<stop offset="1" stopColor="#3CC377"/>
</linearGradient>
<linearGradient id="paint2_linear_142_2721" x1="17.9167" y1="17.5284" x2="18.9167" y2="17.5284" gradientUnits="userSpaceOnUse">
<stop stopColor="#6F5CEC"/>
<stop offset="1" stopColor="#3CC377"/>
</linearGradient>
<linearGradient id="paint3_linear_142_2721" x1="3.25" y1="5.19504" x2="6.91667" y2="5.19504" gradientUnits="userSpaceOnUse">
<stop stopColor="#6F5CEC"/>
<stop offset="1" stopColor="#3CC377"/>
</linearGradient>
<linearGradient id="paint4_linear_142_2721" x1="16.0833" y1="18.0284" x2="19.75" y2="18.0284" gradientUnits="userSpaceOnUse">
<stop stopColor="#6F5CEC"/>
<stop offset="1" stopColor="#3CC377"/>
</linearGradient>
<clipPath id="clip0_142_2721">
<rect width="22" height="22" fill="white" transform="translate(0.5 0.111725)"/>
</clipPath>
</defs>
</svg>
);
}
export default Kai_colored;

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
export const PANEL_SIZES = {
maxWidth: '1360px'
}

View file

@ -15,6 +15,7 @@ import {
import { MODULES } from 'Components/Client/Modules';
import { Icon } from 'UI';
import SVG from 'UI/SVG';
import { hasAi } from 'App/utils/split-utils'
import { useStore } from 'App/mstore';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
@ -41,7 +42,7 @@ function SideMenu(props: Props) {
const isPreferencesActive = location.pathname.includes('/client/');
const [supportOpen, setSupportOpen] = React.useState(false);
const { searchStore, projectsStore, userStore } = useStore();
const { projectsStore, userStore } = useStore();
const spotOnly = userStore.scopeState === 1;
const { account } = userStore;
const modules = account.settings?.modules ?? [];
@ -103,6 +104,7 @@ function SideMenu(props: Props) {
modules.includes(MODULES.USABILITY_TESTS),
item.isAdmin && !isAdmin,
item.isEnterprise && !isEnterprise,
item.key === MENU.KAI && !hasAi
].some((cond) => cond);
return { ...item, hidden: isHidden };
@ -145,6 +147,7 @@ function SideMenu(props: Props) {
[PREFERENCES_MENU.BILLING]: () => client(CLIENT_TABS.BILLING),
[PREFERENCES_MENU.MODULES]: () => client(CLIENT_TABS.MODULES),
[MENU.HIGHLIGHTS]: () => withSiteId(routes.highlights(''), siteId),
[MENU.KAI]: () => withSiteId(routes.kai(), siteId),
};
const handleClick = (item: any) => {

View file

@ -1,10 +1,11 @@
import { TFunction } from 'i18next';
import { IconNames } from "../components/ui/SVG";
import React from 'react';
export interface MenuItem {
label: React.ReactNode;
key: React.Key;
icon?: string;
icon?: IconNames;
children?: MenuItem[];
route?: string;
hidden?: boolean;
@ -53,6 +54,7 @@ export const enum MENU {
SUPPORT = 'support',
EXIT = 'exit',
SPOTS = 'spots',
KAI = 'kai',
}
export const categories: (t: TFunction) => Category[] = (t) => [
@ -93,6 +95,13 @@ export const categories: (t: TFunction) => Category[] = (t) => [
{ label: t('Co-Browse'), key: MENU.LIVE_SESSIONS, icon: 'broadcast' },
],
},
{
title: '',
key: 'kai',
items: [
{ label: t('Kai'), key: MENU.KAI, icon: 'kai' },
],
},
{
title: t('Analytics'),
key: 'analytics',

View file

@ -33,6 +33,8 @@ import UiPlayerStore from './uiPlayerStore';
import userStore from './userStore';
import UxtestingStore from './uxtestingStore';
import WeeklyReportStore from './weeklyReportConfigStore';
import KaiStore from './kaiStore';
import logger from '@/logger';
const projectStore = new ProjectsStore();
@ -81,64 +83,36 @@ const client = new APIClient();
export class RootStore {
dashboardStore: DashboardStore;
metricStore: MetricStore;
funnelStore: FunnelStore;
settingsStore: SettingsStore;
userStore: typeof userStore;
roleStore: RoleStore;
auditStore: AuditStore;
errorStore: ErrorStore;
notificationStore: NotificationStore;
sessionStore: SessionStore;
notesStore: NotesStore;
recordingsStore: RecordingsStore;
assistMultiviewStore: AssistMultiviewStore;
weeklyReportStore: WeeklyReportStore;
alertsStore: AlertStore;
featureFlagsStore: FeatureFlagsStore;
uxtestingStore: UxtestingStore;
tagWatchStore: TagWatchStore;
aiSummaryStore: AiSummaryStore;
aiFiltersStore: AiFiltersStore;
spotStore: SpotStore;
loginStore: LoginStore;
filterStore: FilterStore;
uiPlayerStore: UiPlayerStore;
issueReportingStore: IssueReportingStore;
customFieldStore: CustomFieldStore;
searchStore: SearchStore;
searchStoreLive: SearchStoreLive;
integrationsStore: IntegrationsStore;
projectsStore: ProjectsStore;
kaiStore: KaiStore;
constructor() {
this.dashboardStore = new DashboardStore();
@ -171,6 +145,7 @@ export class RootStore {
this.searchStore = searchStore;
this.searchStoreLive = searchStoreLive;
this.integrationsStore = new IntegrationsStore();
this.kaiStore = new KaiStore();
}
initClient() {

View file

@ -0,0 +1,75 @@
import { makeAutoObservable } from 'mobx';
type threadId = number | string;
export interface RecentChat {
title: string;
thread_id: threadId;
}
export interface ChartData {
timestamp: number,
[key: string]: number
}
export interface Message {
role: 'human' | 'kai';
content: string;
chart?: { type: string, data?: ChartData[] }
message_id: number;
}
export default class KaiStore {
recentChats: RecentChat[] = [];
chats: Record<threadId, Message[]> = {};
activeThreadId: threadId = 0;
loadingHistory = false;
loadingAnswer = false;
status = 'thinking';
constructor() {
makeAutoObservable(this);
}
setActiveThreadId = (threadId: threadId) => {
this.activeThreadId = threadId;
}
setStatus = (status: string) => {
this.status = status;
}
setLoadingHistory = (loading: boolean) => {
this.loadingHistory = loading;
}
setLoadingAnswer = (loading: boolean) => {
this.loadingAnswer = loading;
}
addRecentChat = (chat: RecentChat) => {
this.recentChats.push(chat);
}
setRecentChats = (chats: RecentChat[]) => {
this.recentChats = chats;
}
addMessage = (threadId: number, message: Message) => {
const id = `_${threadId}` as threadId;
if (!this.chats[id]) {
this.chats[id] = [];
}
this.chats[id].push(message);
}
setMessages = (threadId: number, messages: Message[]) => {
const id = `_${threadId}` as threadId;
this.chats[id] = messages;
}
getMessages = (threadId: number) => {
const id = `_${threadId}` as threadId;
return this.chats[id] || [];
}
}

View file

@ -200,6 +200,8 @@ export const scopeSetup = (): string => '/scope-setup';
export const highlights = (): string => '/highlights';
export const kai = (): string => '/kai';
const REQUIRED_SITE_ID_ROUTES = [
liveSession(''),
session(''),
@ -240,6 +242,8 @@ const REQUIRED_SITE_ID_ROUTES = [
usabilityTestingView(''),
highlights(),
kai(),
];
const routeNeedsSiteId = (path: string): boolean =>
REQUIRED_SITE_ID_ROUTES.some((r) => path.startsWith(r));

View file

@ -84,4 +84,71 @@ export default class AiService extends BaseService {
const { data } = await r.json();
return data;
}
getKaiChats = async (userId: string, projectId: string): Promise<{ title: string, threadId: string }[]> => {
// const r = await this.client.get('/kai/PROJECT_ID/chats');
const jwt = window.env.KAI_TESTING // this.client.getJwt()
const r = await fetch(`http://localhost:8700/kai/${projectId}/chats?user_id=${userId}`, {
headers: new Headers({
Accept: 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${jwt}`
}),
});
if (!r.ok) {
throw new Error('Failed to fetch chats');
}
const data = await r.json();
return data;
}
deleteKaiChat = async (projectId: string, userId: string, threadId: string): Promise<boolean> => {
const jwt = window.env.KAI_TESTING // this.client.getJwt()
const r = await fetch(`http://localhost:8700/kai/${projectId}/chats/${threadId}?user_id=${userId}`, {
method: 'DELETE',
headers: new Headers({
Accept: 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${jwt}`
}),
});
if (!r.ok) {
throw new Error('Failed to delete chat');
}
return true;
}
getKaiChat = async (projectId: string, userId: string, threadId: string): Promise<{ role: string, content: string, message_id: any }[]> => {
const jwt = window.env.KAI_TESTING // this.client.getJwt()
const r = await fetch(`http://localhost:8700/kai/${projectId}/chats/${threadId}?user_id=${userId}`, {
method: 'GET',
headers: new Headers({
Accept: 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${jwt}`
}),
});
if (!r.ok) {
throw new Error('Failed to fetch chat');
}
const data = await r.json();
return data;
}
createKaiChat = async (projectId: string, userId: string): Promise<number> => {
const jwt = window.env.KAI_TESTING // this.client.getJwt()
const r = await fetch(`http://localhost:8700/kai/${projectId}/chat/new?user_id=${userId}`, {
method: 'GET',
headers: new Headers({
Accept: 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${jwt}`
}),
})
if (!r.ok) {
throw new Error('Failed to create chat');
}
const data = await r.json();
return data;
}
}

View file

@ -1,5 +1,4 @@
@import 'react-toastify/dist/ReactToastify.css';
@import "react-daterange-picker/dist/css/react-calendar.css";
@import 'rc-time-picker/assets/index.css';
@import 'react-daterange-picker.css';
@import 'rc-time-picker.css';
@ -8,4 +7,4 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind utilities;

View file

@ -0,0 +1,9 @@
<svg viewBox="0 0 14 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon &#60;- change size here">
<path id="Vector" d="M7 1.86172L5.88467 5.25264C5.82759 5.42619 5.73055 5.5839 5.60136 5.71309C5.47218 5.84227 5.31446 5.93931 5.14092 5.99639L1.75 7.11172L5.14092 8.22706C5.31446 8.28414 5.47218 8.38118 5.60136 8.51036C5.73055 8.63954 5.82759 8.79726 5.88467 8.97081L7 12.3617L8.11533 8.97081C8.17241 8.79726 8.26945 8.63954 8.39864 8.51036C8.52782 8.38118 8.68554 8.28414 8.85908 8.22706L12.25 7.11172L8.85908 5.99639C8.68554 5.93931 8.52782 5.84227 8.39864 5.71309C8.26945 5.5839 8.17241 5.42619 8.11533 5.25264L7 1.86172Z" stroke="#394EFF" stroke-linecap="round" stroke-linejoin="round" fill="white"/>
<path id="Vector_2" d="M2.91663 1.86172V4.19506" stroke="#394EFF" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M11.0834 10.0284V12.3617" stroke="#394EFF" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_4" d="M1.75 3.02838H4.08333" stroke="#394EFF" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_5" d="M9.91663 11.1951H12.25" stroke="#394EFF" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,46 @@
<svg viewBox="0 0 23 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon &#60;- change size here" clip-path="url(#clip0_142_2721)">
<path id="Vector" d="M11.5 2.86172L9.74733 8.19031C9.65764 8.46302 9.50514 8.71086 9.30214 8.91387C9.09914 9.11687 8.8513 9.26936 8.57858 9.35906L3.25 11.1117L8.57858 12.8644C8.8513 12.9541 9.09914 13.1066 9.30214 13.3096C9.50514 13.5126 9.65764 13.7604 9.74733 14.0331L11.5 19.3617L13.2527 14.0331C13.3424 13.7604 13.4949 13.5126 13.6979 13.3096C13.9009 13.1066 14.1487 12.9541 14.4214 12.8644L19.75 11.1117L14.4214 9.35906C14.1487 9.26936 13.9009 9.11687 13.6979 8.91387C13.4949 8.71086 13.3424 8.46302 13.2527 8.19031L11.5 2.86172Z" fill="white" stroke="url(#paint0_linear_142_2721)" stroke-width="2"/>
<g id="Vector_2">
<path d="M5.08331 2.86172V6.52839V2.86172Z" fill="white"/>
<path d="M5.08331 2.86172V6.52839" stroke="url(#paint1_linear_142_2721)" stroke-width="2"/>
</g>
<g id="Vector_3">
<path d="M17.9167 15.695V19.3617V15.695Z" fill="white"/>
<path d="M17.9167 15.695V19.3617" stroke="url(#paint2_linear_142_2721)" stroke-width="2"/>
</g>
<g id="Vector_4">
<path d="M3.25 4.69504H6.91667H3.25Z" fill="white"/>
<path d="M3.25 4.69504H6.91667" stroke="url(#paint3_linear_142_2721)" stroke-width="2"/>
</g>
<g id="Vector_5">
<path d="M16.0833 17.5284H19.75H16.0833Z" fill="white"/>
<path d="M16.0833 17.5284H19.75" stroke="url(#paint4_linear_142_2721)" stroke-width="2"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_142_2721" x1="3.25" y1="11.1117" x2="19.75" y2="11.1117" gradientUnits="userSpaceOnUse">
<stop stop-color="#6F5CEC"/>
<stop offset="1" stop-color="#3CC377"/>
</linearGradient>
<linearGradient id="paint1_linear_142_2721" x1="5.08331" y1="4.69506" x2="6.08331" y2="4.69506" gradientUnits="userSpaceOnUse">
<stop stop-color="#6F5CEC"/>
<stop offset="1" stop-color="#3CC377"/>
</linearGradient>
<linearGradient id="paint2_linear_142_2721" x1="17.9167" y1="17.5284" x2="18.9167" y2="17.5284" gradientUnits="userSpaceOnUse">
<stop stop-color="#6F5CEC"/>
<stop offset="1" stop-color="#3CC377"/>
</linearGradient>
<linearGradient id="paint3_linear_142_2721" x1="3.25" y1="5.19504" x2="6.91667" y2="5.19504" gradientUnits="userSpaceOnUse">
<stop stop-color="#6F5CEC"/>
<stop offset="1" stop-color="#3CC377"/>
</linearGradient>
<linearGradient id="paint4_linear_142_2721" x1="16.0833" y1="18.0284" x2="19.75" y2="18.0284" gradientUnits="userSpaceOnUse">
<stop stop-color="#6F5CEC"/>
<stop offset="1" stop-color="#3CC377"/>
</linearGradient>
<clipPath id="clip0_142_2721">
<rect width="22" height="22" fill="white" transform="translate(0.5 0.111725)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,5 @@
/**
* can be overwritten in saas or ee editions
* */
export const hasAi = true //DEBUG false;

View file

@ -68,13 +68,13 @@
"rc-time-picker": "^3.7.3",
"react": "^19.1.0",
"react-confirm": "^0.3.0",
"react-daterange-picker": "^2.0.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^19.1.0",
"react-google-recaptcha": "^2.1.0",
"react-i18next": "^15.5.1",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^10.1.0",
"react-router": "^5.3.3",
"react-router-dom": "^5.3.3",
"react-select": "^5.10.1",

File diff suppressed because it is too large Load diff

View file

@ -1,4 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1