* 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

* ui: add logs, add threadid

* ui: feedback methods and ui

* ui: store, replacing messages and giving feedback

* ui: move retry btn to right corner

* ui: move kai service out for ease of code splitting

* ui: add thread id to socket connection

* ui: support state messages

* ui: cancel response generation method

* ui: fix toast str

* ui: add gfm plugin

* ui: ensure md table has max sizes to prevent overflow

* ui: revert tailwind styles on markdown block layer

* ui: export as pdf, copy text contents of a message

* ui: try to save text with formatting in secure contexts

* ui: fix types

* ui: fixup dark mode colors

* ui: add duration for msgs

* ui: take out custom jwt

* ui: removing hardcode...

* ui: change endpoints to prod

* ui: swap socket path

* ui: flip vis toggle

* ui: lock file regenerate
This commit is contained in:
Delirium 2025-05-13 14:00:09 +02:00 committed by GitHub
parent 4e331b70a4
commit dca5e54811
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 3532 additions and 1119 deletions

View file

@ -2,6 +2,6 @@ compressionLevel: 1
enableGlobalCache: true
nodeLinker: pnpm
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.7.0.cjs

View file

@ -10,6 +10,7 @@ import { Loader } from 'UI';
import APIClient from './api_client';
import * as routes from './routes';
import { debounceCall } from '@/utils';
import { hasAi } from './utils/split-utils';
const components: any = {
SessionPure: lazy(() => import('Components/Session/Session')),
@ -32,7 +33,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 +54,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 +100,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 +274,12 @@ function PrivateRoutes() {
path={withSiteId(LIVE_SESSION_PATH, siteIdList)}
component={enhancedComponents.LiveSession}
/>
{hasAi ? <Route
exact
strict
path={withSiteId(KAI_PATH, siteIdList)}
component={enhancedComponents.Kai}
/> : null}
{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,187 @@
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 { kaiService } 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
projectId={activeSiteId}
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) => {
if (firstMsg) {
setInitialMsg(firstMsg);
}
const newThread = await kaiService.createKaiChat(activeSiteId);
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%, var(--color-glassWhite) 0%, var(--color-glassMint) 46%, var(--color-glassLavander) 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,
projectId,
}: {
onSelect: (threadId: string, title: string) => void;
projectId: string;
}) {
const {
data = [],
isPending,
refetch,
} = useQuery({
queryKey: ['kai', 'chats'],
queryFn: () => kaiService.getKaiChats(projectId),
staleTime: 1000 * 60,
});
const onDelete = async (id: string) => {
try {
await kaiService.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,61 @@
import AiService from "@/services/AiService";
export default class KaiService extends AiService {
getKaiChats = async (projectId: string): Promise<{ title: string, threadId: string }[]> => {
const r = await this.client.get(`/kai/${projectId}/chats`);
if (!r.ok) {
throw new Error('Failed to fetch chats');
}
const data = await r.json();
return data;
}
deleteKaiChat = async (projectId: string, threadId: string): Promise<boolean> => {
const r = await this.client.delete(`/kai/${projectId}/chats/${threadId}`);
if (!r.ok) {
throw new Error('Failed to delete chat');
}
return true;
}
getKaiChat = async (projectId: string, threadId: string): Promise<{ role: string, content: string, message_id: any, duration?: number }[]> => {
const r = await this.client.get(`/kai/${projectId}/chats/${threadId}`);
if (!r.ok) {
throw new Error('Failed to fetch chat');
}
const data = await r.json();
return data;
}
createKaiChat = async (projectId: string): Promise<number> => {
const r = await this.client.get(`/kai/${projectId}/chat/new`)
if (!r.ok) {
throw new Error('Failed to create chat');
}
const data = await r.json();
return data;
}
feedback = async (positive: boolean | null, messageId: string, projectId: string) => {
const r = await this.client.post(`/kai/${projectId}/messages/feedback`, {
message_id: messageId,
value: positive,
user_id: userId,
});
if (!r.ok) {
throw new Error('Failed to send feedback');
}
return await r.json()
}
cancelGeneration = async (projectId: string, threadId: string) => {
const r = await this.client.post(`/kai/${projectId}/cancel/${threadId}`);
if (!r.ok) {
throw new Error('Failed to cancel generation');
}
const data = await r.json();
return data;
}
}

View file

@ -0,0 +1,231 @@
import { makeAutoObservable, runInAction } from 'mobx';
import { BotChunk, ChatManager, Message } from './SocketManager';
import { kaiService as aiService, kaiService } from 'App/services';
import { toast } from 'react-toastify';
class KaiStore {
chatManager: ChatManager | null = null;
processingStage: BotChunk | null = null;
messages: Message[] = [];
queryText = '';
loadingChat = false;
replacing = false;
constructor() {
makeAutoObservable(this);
}
get lastHumanMessage() {
let msg = null;
let index = null;
for (let i = this.messages.length - 1; i >= 0; i--) {
const message = this.messages[i];
if (message.isUser) {
msg = message;
index = i;
break;
}
}
return { msg, index };
}
get lastKaiMessage() {
let msg = null;
let index = null;
for (let i = this.messages.length - 1; i >= 0; i--) {
const message = this.messages[i];
if (!message.isUser) {
msg = message;
index = i;
break;
}
}
return { msg, index };
}
setQueryText = (text: string) => {
this.queryText = text;
};
setLoadingChat = (loading: boolean) => {
this.loadingChat = loading;
};
setChatManager = (chatManager: ChatManager) => {
this.chatManager = chatManager;
};
setProcessingStage = (stage: BotChunk | null) => {
this.processingStage = stage;
};
setMessages = (messages: Message[]) => {
this.messages = messages;
};
addMessage = (message: Message) => {
this.messages.push(message);
};
editMessage = (text: string) => {
this.setQueryText(text);
this.setReplacing(true);
};
replaceAtIndex = (message: Message, index: number) => {
const messages = [...this.messages];
messages[index] = message;
this.setMessages(messages);
};
deleteAtIndex = (indexes: number[]) => {
if (!indexes.length) return;
const messages = this.messages.filter((_, i) => !indexes.includes(i));
console.log(messages, indexes)
runInAction(() => {
this.messages = messages;
})
}
getChat = async (projectId: string, threadId: string) => {
this.setLoadingChat(true);
try {
const res = await aiService.getKaiChat(projectId, threadId);
if (res && res.length) {
this.setMessages(
res.map((m) => {
const isUser = m.role === 'human';
return {
text: m.content,
isUser: isUser,
messageId: m.message_id,
duration: m.duration,
};
}),
);
}
} catch (e) {
console.error(e);
toast.error("Couldn't load chat history. Please try again later.");
} finally {
this.setLoadingChat(false);
}
};
createChatManager = (
settings: { projectId: string; threadId: string },
setTitle: (title: string) => void,
initialMsg: string | null,
) => {
const token = kaiService.client.getJwt()
this.chatManager = new ChatManager({ ...settings, token });
this.chatManager.setOnMsgHook({
msgCallback: (msg) => {
if ('state' in msg) {
if (msg.state === 'running') {
this.setProcessingStage({
content: 'Processing your request...',
stage: 'chart',
messageId: Date.now().toPrecision(),
duration: msg.start_time ? Date.now() - msg.start_time : 0
})
} else {
this.setProcessingStage(null)
}
} else {
if (msg.stage === 'start') {
this.setProcessingStage({
...msg,
content: 'Processing your request...',
});
}
if (msg.stage === 'chart') {
this.setProcessingStage(msg);
}
if (msg.stage === 'final') {
const msgObj = {
text: msg.content,
isUser: false,
messageId: msg.messageId,
duration: msg.duration
}
this.addMessage(msgObj);
this.setProcessingStage(null);
}
}
},
titleCallback: setTitle,
});
if (initialMsg) {
this.sendMessage(initialMsg);
}
};
setReplacing = (replacing: boolean) => {
this.replacing = replacing;
};
sendMessage = (message: string) => {
if (this.chatManager) {
this.chatManager.sendMessage(message, this.replacing);
}
if (this.replacing) {
console.log(this.lastHumanMessage, this.lastKaiMessage, 'replacing these two')
const deleting = []
if (this.lastHumanMessage.index !== null) {
deleting.push(this.lastHumanMessage.index);
}
if (this.lastKaiMessage.index !== null) {
deleting.push(this.lastKaiMessage.index)
}
this.deleteAtIndex(deleting);
this.setReplacing(false)
}
this.addMessage({
text: message,
isUser: true,
messageId: Date.now().toString(),
});
};
sendMsgFeedback = (feedback: string, messageId: string) => {
const settings = { projectId: '2325', userId: '0' };
aiService
.feedback(
feedback === 'like',
messageId,
settings.projectId,
)
.then(() => {
toast.success('Feedback saved.');
})
.catch((e) => {
console.error(e);
toast.error('Failed to send feedback. Please try again later.');
});
};
cancelGeneration = async (settings: { projectId: string; userId: string; threadId: string }) => {
try {
await kaiService.cancelGeneration(settings.projectId, settings.threadId, settings.userId)
this.setProcessingStage(null)
} catch (e) {
console.error(e)
toast.error('Failed to cancel the response generation, please try again later.')
}
}
clearChat = () => {
this.setMessages([]);
this.setProcessingStage(null);
this.setLoadingChat(false);
this.setQueryText('');
if (this.chatManager) {
this.chatManager.disconnect();
this.chatManager = null;
}
};
}
export const kaiStore = new KaiStore();

View file

@ -0,0 +1,110 @@
import io from 'socket.io-client';
export class ChatManager {
socket: ReturnType<typeof io>;
threadId: string | null = null;
constructor({
projectId,
threadId,
token,
}: {
projectId: string;
threadId: string;
token: string;
}) {
this.threadId = threadId;
const urlObject = new URL(window.env.API_EDP || window.location.origin);
const socket = io(`${urlObject.origin}/kai/chat`, {
transports: ['websocket'],
path: '/kai/chat/socket.io',
autoConnect: true,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
withCredentials: true,
multiplex: true,
query: {
project_id: projectId,
thread_id: threadId,
},
auth: {
token: `Bearer ${token}`,
},
});
socket.on('connect', () => {
console.log('Connected to server');
});
socket.on('disconnect', () => {
console.log('Disconnected from server');
});
socket.on('error', (err) => {
console.error('Socket error:', err);
});
socket.onAny((e) => console.log('event', e));
this.socket = socket;
}
sendMessage = (message: string, isReplace = false) => {
this.socket.emit(
'message',
JSON.stringify({
message,
threadId: this.threadId,
replace: isReplace,
}),
);
};
setOnMsgHook = ({
msgCallback,
titleCallback,
}: {
msgCallback: (
msg: BotChunk | { state: string; type: 'state'; start_time?: number },
) => 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);
});
this.socket.on(
'state',
(state: { message: 'idle' | 'running'; start_time: number }) => {
msgCallback({
state: state.message,
type: 'state',
start_time: state.start_time,
});
},
);
};
disconnect = () => {
this.socket.disconnect();
};
}
export interface BotChunk {
stage: 'start' | 'chart' | 'final' | 'title';
content: string;
messageId: string;
duration?: number;
}
export interface Message {
text: string;
isUser: boolean;
messageId: string;
duration?: number;
}
export interface SentMessage extends Message {
replace: 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,55 @@
import React from 'react'
import { Button, Input } from "antd";
import { SendHorizonal, OctagonX } from "lucide-react";
import { kaiStore } from "../KaiStore";
import { observer } from "mobx-react-lite";
function ChatInput({ isLoading, onSubmit, threadId }: { isLoading?: boolean, onSubmit: (str: string) => void, threadId: string }) {
const inputRef = React.useRef<Input>(null);
const inputValue = kaiStore.queryText;
const isProcessing = kaiStore.processingStage !== null
const setInputValue = (text: string) => {
kaiStore.setQueryText(text)
}
const submit = () => {
if (isProcessing) {
const settings = { projectId: '2325', userId: '0', threadId, };
void kaiStore.cancelGeneration(settings)
} else {
if (inputValue.length > 0) {
onSubmit(inputValue)
setInputValue('')
}
}
}
React.useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
}
}, [inputValue])
return (
<Input
onPressEnter={submit}
ref={inputRef}
placeholder={'Ask anything about your product and users...'}
size={'large'}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
suffix={
<Button
loading={isLoading}
onClick={submit}
icon={isProcessing ? <OctagonX size={16} /> : <SendHorizonal size={16} />}
type={'text'}
size={'small'}
shape={'circle'}
/>
}
/>
)
}
export default observer(ChatInput)

View file

@ -0,0 +1,88 @@
import React from 'react';
import ChatInput from './ChatInput';
import { ChatMsg, ChatNotice } from './ChatMsg';
import { Loader } from 'UI';
import { kaiStore } from '../KaiStore'
import { observer } from 'mobx-react-lite';
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 messages = kaiStore.messages;
const loading = kaiStore.loadingChat;
const chatRef = React.useRef<HTMLDivElement>(null);
const processingStage = kaiStore.processingStage;
React.useEffect(() => {
const settings = { projectId, threadId };
if (threadId && !initialMsg) {
void kaiStore.getChat(settings.projectId, threadId)
}
if (threadId) {
kaiStore.createChatManager(settings, onTitleChange, initialMsg)
}
return () => {
kaiStore.clearChat();
setInitialMsg(null);
};
}, [threadId]);
const onSubmit = (text: string) => {
kaiStore.sendMessage(text)
};
React.useEffect(() => {
chatRef.current?.scrollTo({
top: chatRef.current.scrollHeight,
behavior: 'smooth',
});
}, [messages.length, processingStage]);
const lastHumanMsgInd: null | number = kaiStore.lastHumanMessage.index;
return (
<Loader loading={loading} 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}
messageId={msg.messageId}
isLast={index === lastHumanMsgInd}
duration={msg.duration}
/>
))}
{processingStage ? (
<ChatNotice content={processingStage.content} duration={processingStage.duration} />
) : null}
</div>
<div className={'sticky bottom-0 pt-6 w-2/3'}>
<ChatInput onSubmit={onSubmit} threadId={threadId} />
</div>
</div>
</Loader>
);
}
export default observer(ChatLog);

View file

@ -0,0 +1,173 @@
import React from 'react';
import { Icon, CopyButton } from 'UI';
import cn from 'classnames';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm'
import { Loader, ThumbsUp, ThumbsDown, ListRestart, FileDown, Clock } from 'lucide-react';
import { Button, Tooltip } from 'antd';
import { kaiStore } from '../KaiStore';
import { toast } from 'react-toastify';
import { durationFormatted } from 'App/date';
export function ChatMsg({
text,
isUser,
userName,
messageId,
isLast,
duration,
}: {
text: string;
isUser: boolean;
messageId: string;
userName?: string;
isLast?: boolean;
duration?: number;
}) {
const [isProcessing, setIsProcessing] = React.useState(false);
const bodyRef = React.useRef<HTMLDivElement>(null);
const onRetry = () => {
kaiStore.editMessage(text)
}
const onFeedback = (feedback: 'like' | 'dislike', messageId: string) => {
kaiStore.sendMsgFeedback(feedback, messageId);
};
const onExport = () => {
setIsProcessing(true);
import('jspdf').then(({ jsPDF }) => {
const doc = new jsPDF();
doc.html(bodyRef.current, {
callback: function(doc) {
doc.save('document.pdf');
},
margin: [10, 10, 10, 10],
x: 0,
y: 0,
width: 190, // Target width
windowWidth: 675 // Window width for rendering
});
})
.catch(e => {
console.error('Error exporting message:', e);
toast.error('Failed to export message');
})
.finally(() => {
setIsProcessing(false);
});
}
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 flex flex-col'}>
<div className='markdown-body' ref={bodyRef}>
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
</div>
{isUser ? (
isLast ? (
<div
onClick={onRetry}
className={
'ml-auto flex items-center gap-2 px-2 rounded-lg border border-gray-medium text-sm cursor-pointer hover:border-main hover:text-main w-fit'
}
>
<ListRestart size={16} />
<div>Edit</div>
</div>
) : null
) : (
<div className={'flex items-center gap-2'}>
{duration ? (
<MsgDuration duration={duration} />
) : null}
<div className='ml-auto' />
<IconButton tooltip="Like this answer" onClick={() => onFeedback('like', messageId)}>
<ThumbsUp size={16} />
</IconButton>
<IconButton tooltip="Dislike this answer" onClick={() => onFeedback('dislike', messageId)}>
<ThumbsDown size={16} />
</IconButton>
<CopyButton getHtml={() => bodyRef.current?.innerHTML} content={text} isIcon format={'text/html'} />
<IconButton processing={isProcessing} tooltip="Export as PDF" onClick={onExport}>
<FileDown size={16} />
</IconButton>
</div>
)}
</div>
</div>
);
}
function IconButton({
children,
onClick,
tooltip,
processing,
}: {
children: React.ReactNode;
onClick?: () => void;
tooltip?: string;
processing?: boolean;
}) {
return (
<Tooltip title={tooltip}>
<Button onClick={onClick} type="text" icon={children} size='small' loading={processing} />
</Tooltip>
);
}
export function ChatNotice({ content, duration }: { content: string, duration?: number }) {
const startTime = React.useRef(duration ? Date.now() - duration : Date.now());
const [activeDuration, setDuration] = React.useState(duration ?? 0);
React.useEffect(() => {
const interval = setInterval(() => {
setDuration(Math.round((Date.now() - startTime.current)));
}, 250);
return () => clearInterval(interval);
}, []);
return (
<div className='flex flex-col gap-1 items-start p-2 rounded-lg bg-gray-lightest border-gray-light w-fit '>
<div className="flex gap-2 items-start">
<div className={'animate-spin mt-1'}>
<Loader size={14} />
</div>
<div className={'animate-pulse'}>{content}</div>
</div>
<MsgDuration duration={activeDuration} />
</div>
);
}
function MsgDuration({ duration }: { duration: number }) {
return (
<div className="text-disabled-text text-sm flex items-center gap-1">
<Clock size={14} />
<span className="leading-none">
{durationFormatted(duration)}
</span>
</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

@ -1,34 +0,0 @@
import React, { useState } from 'react';
import copy from 'copy-to-clipboard';
import { Button } from 'antd';
function CopyButton({
content,
variant = 'text',
className = 'capitalize mt-2 font-medium text-neutral-400',
btnText = 'copy',
size = 'small',
}) {
const [copied, setCopied] = useState(false);
const copyHandler = () => {
setCopied(true);
copy(content);
setTimeout(() => {
setCopied(false);
}, 1000);
};
return (
<Button
type={variant}
onClick={copyHandler}
size={size}
className={className}
>
{copied ? 'copied' : btnText}
</Button>
);
}
export default CopyButton;

View file

@ -0,0 +1,83 @@
import React, { useState } from 'react';
import copy from 'copy-to-clipboard';
import { Button, Tooltip } from 'antd';
import { ClipboardCopy, ClipboardCheck } from 'lucide-react';
interface Props {
content: string;
getHtml?: () => any;
variant?: 'text' | 'primary' | 'ghost' | 'link' | 'default';
className?: string;
btnText?: string;
size?: 'small' | 'middle' | 'large';
isIcon?: boolean;
format?: string;
}
function CopyButton({
content,
getHtml,
variant = 'text',
className = 'capitalize mt-2 font-medium text-neutral-400',
btnText = 'copy',
size = 'small',
isIcon = false,
format = 'text/plain',
}: Props) {
const [copied, setCopied] = useState(false);
const reset = () => {
setTimeout(() => {
setCopied(false);
}, 1000);
}
const copyHandler = () => {
setCopied(true);
const contentIsGetter = !!getHtml
const textContent = contentIsGetter ? getHtml() : content;
const isHttps = window.location.protocol === 'https:';
if (!isHttps) {
copy(textContent);
reset();
return;
}
const blob = new Blob([textContent], { type: format });
const cbItem = new ClipboardItem({
[format]: blob
})
navigator.clipboard.write([cbItem])
.catch(e => {
copy(textContent);
})
.finally(() => {
reset()
})
};
if (isIcon) {
return (
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
<Button
type="text"
onClick={copyHandler}
size={size}
icon={
copied ? <ClipboardCheck size={16} /> : <ClipboardCopy size={16} />
}
/>
</Tooltip>
);
}
return (
<Button
type={variant}
onClick={copyHandler}
size={size}
className={className}
>
{copied ? 'copied' : btnText}
</Button>
);
}
export default CopyButton;

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

@ -81,63 +81,34 @@ 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;
constructor() {

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

@ -25,6 +25,7 @@ import IssueReportsService from './IssueReportsService';
import CustomFieldService from './CustomFieldService';
import IntegrationsService from './IntegrationsService';
import ProjectsService from './ProjectsService';
import KaiService from '@/components/Kai/KaiService';
export const dashboardService = new DashboardService();
export const metricService = new MetricService();
@ -52,6 +53,7 @@ export const customFieldService = new CustomFieldService();
export const integrationsService = new IntegrationsService();
export const searchService = new SearchService();
export const projectsService = new ProjectsService();
export const kaiService = new KaiService();
export const services = [
projectsService,
@ -80,4 +82,5 @@ export const services = [
customFieldService,
integrationsService,
searchService,
kaiService,
];

View file

@ -47,6 +47,12 @@
.fill-transparent { fill: var(--color-transparent) }
.fill-cyan { fill: var(--color-cyan) }
.fill-amber { fill: var(--color-amber) }
.fill-glassWhite { fill: var(--color-glassWhite) }
.fill-glassMint { fill: var(--color-glassMint) }
.fill-glassLavander { fill: var(--color-glassLavander) }
.fill-blueLight { fill: var(--color-blueLight) }
.fill-offWhite { fill: var(--color-offWhite) }
.fill-disabled-text { fill: var(--color-disabled-text) }
.fill-figmaColors-accent-secondary { fill: var(--color-figmaColors-accent-secondary) }
.fill-figmaColors-main { fill: var(--color-figmaColors-main) }
.fill-figmaColors-primary-outlined-hover-background { fill: var(--color-figmaColors-primary-outlined-hover-background) }
@ -102,6 +108,12 @@
.hover-fill-transparent:hover svg { fill: var(--color-transparent) }
.hover-fill-cyan:hover svg { fill: var(--color-cyan) }
.hover-fill-amber:hover svg { fill: var(--color-amber) }
.hover-fill-glassWhite:hover svg { fill: var(--color-glassWhite) }
.hover-fill-glassMint:hover svg { fill: var(--color-glassMint) }
.hover-fill-glassLavander:hover svg { fill: var(--color-glassLavander) }
.hover-fill-blueLight:hover svg { fill: var(--color-blueLight) }
.hover-fill-offWhite:hover svg { fill: var(--color-offWhite) }
.hover-fill-disabled-text:hover svg { fill: var(--color-disabled-text) }
.hover-fill-figmaColors-accent-secondary:hover svg { fill: var(--color-figmaColors-accent-secondary) }
.hover-fill-figmaColors-main:hover svg { fill: var(--color-figmaColors-main) }
.hover-fill-figmaColors-primary-outlined-hover-background:hover svg { fill: var(--color-figmaColors-primary-outlined-hover-background) }
@ -159,6 +171,12 @@
.color-transparent { color: var(--color-transparent) }
.color-cyan { color: var(--color-cyan) }
.color-amber { color: var(--color-amber) }
.color-glassWhite { color: var(--color-glassWhite) }
.color-glassMint { color: var(--color-glassMint) }
.color-glassLavander { color: var(--color-glassLavander) }
.color-blueLight { color: var(--color-blueLight) }
.color-offWhite { color: var(--color-offWhite) }
.color-disabled-text { color: var(--color-disabled-text) }
.color-figmaColors-accent-secondary { color: var(--color-figmaColors-accent-secondary) }
.color-figmaColors-main { color: var(--color-figmaColors-main) }
.color-figmaColors-primary-outlined-hover-background { color: var(--color-figmaColors-primary-outlined-hover-background) }
@ -216,6 +234,12 @@
.hover-transparent:hover { color: var(--color-transparent) }
.hover-cyan:hover { color: var(--color-cyan) }
.hover-amber:hover { color: var(--color-amber) }
.hover-glassWhite:hover { color: var(--color-glassWhite) }
.hover-glassMint:hover { color: var(--color-glassMint) }
.hover-glassLavander:hover { color: var(--color-glassLavander) }
.hover-blueLight:hover { color: var(--color-blueLight) }
.hover-offWhite:hover { color: var(--color-offWhite) }
.hover-disabled-text:hover { color: var(--color-disabled-text) }
.hover-figmaColors-accent-secondary:hover { color: var(--color-figmaColors-accent-secondary) }
.hover-figmaColors-main:hover { color: var(--color-figmaColors-main) }
.hover-figmaColors-primary-outlined-hover-background:hover { color: var(--color-figmaColors-primary-outlined-hover-background) }
@ -273,6 +297,12 @@
.border-transparent { border-color: var(--color-transparent) }
.border-cyan { border-color: var(--color-cyan) }
.border-amber { border-color: var(--color-amber) }
.border-glassWhite { border-color: var(--color-glassWhite) }
.border-glassMint { border-color: var(--color-glassMint) }
.border-glassLavander { border-color: var(--color-glassLavander) }
.border-blueLight { border-color: var(--color-blueLight) }
.border-offWhite { border-color: var(--color-offWhite) }
.border-disabled-text { border-color: var(--color-disabled-text) }
.border-figmaColors-accent-secondary { border-color: var(--color-figmaColors-accent-secondary) }
.border-figmaColors-main { border-color: var(--color-figmaColors-main) }
.border-figmaColors-primary-outlined-hover-background { border-color: var(--color-figmaColors-primary-outlined-hover-background) }
@ -330,6 +360,12 @@
.bg-transparent { background-color: var(--color-transparent) }
.bg-cyan { background-color: var(--color-cyan) }
.bg-amber { background-color: var(--color-amber) }
.bg-glassWhite { background-color: var(--color-glassWhite) }
.bg-glassMint { background-color: var(--color-glassMint) }
.bg-glassLavander { background-color: var(--color-glassLavander) }
.bg-blueLight { background-color: var(--color-blueLight) }
.bg-offWhite { background-color: var(--color-offWhite) }
.bg-disabled-text { background-color: var(--color-disabled-text) }
.bg-figmaColors-accent-secondary { background-color: var(--color-figmaColors-accent-secondary) }
.bg-figmaColors-main { background-color: var(--color-figmaColors-main) }
.bg-figmaColors-primary-outlined-hover-background { background-color: var(--color-figmaColors-primary-outlined-hover-background) }

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

@ -287,3 +287,141 @@ svg {
.text-black {
color: $black;
}
.markdown-body table {
table-layout: auto;
width: 100%;
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
display: block;
max-width: 960px;
overflow-x: scroll;
}
.markdown-body table::-webkit-scrollbar {
height: 4px;
background-color: var(--color-gray-lightest);
}
.markdown-body table::-webkit-scrollbar-thumb {
background-color: var(--color-gray-medium);
border-radius: 4px;
cursor: grab;
}
.markdown-body table::-webkit-scrollbar-thumb:hover {
background-color: var(--color-gray-dark);
}
.markdown-body table::-webkit-scrollbar-thumb:active {
background-color: var(--color-gray-darkest);
}
.markdown-body table::-webkit-scrollbar-track {
background-color: var(--color-gray-lightest);
}
.markdown-body dl dt {
padding: 0;
margin-top: 16px;
font-size: 1em;
font-style: italic;
font-weight: 600;
}
.markdown-body dl dd {
padding: 0 16px;
margin-bottom: 16px;
}
.markdown-body table th {
font-weight: 600;
}
.markdown-body table th,
.markdown-body table td {
padding: 3px 6px;
border: 1px solid $gray-light;
}
.markdown-body table td>:last-child {
margin-bottom: 0;
}
.markdown-body table thead {
background-color: var(--color-blueLight);
}
.markdown-body table tbody tr {
background-color: var(--color-offWhite);
}
.markdown-body table tbody td {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 30px;
max-width: 300px;
}
.markdown-body table tr:nth-child(2n) {
background-color: var(--color-blueLight);
}
.markdown-body table img {
background-color: transparent;
}
.markdown-body * {
all: revert-layer;
}
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 {
font-size: revert;
font-weight: revert;
}
.markdown-body h1 {
font-size: 1.5rem;
margin: 0;
}
.markdown-body h2 {
font-size: 1.25rem;
margin: 0;
}
.markdown-body h3 {
font-size: 1.125rem;
margin: 0;
}
.markdown-body h4 {
font-size: 1rem;
margin: 0;
}
.markdown-body a {
color: var(--color-teal);
text-decoration: none;
}
.markdown-body p {
margin: 0!important;
}
.markdown-body ul {
margin-top: 4px!important;
margin-bottom: 4px!important;
}
.markdown-body ul {
list-style-type: none; /* Remove default bullets */
}
.markdown-body ul li:before {
content: "•"; /* Use standard bullet character */
display: inline-block;
width: 1em;
margin-left: -1em;
}

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

@ -46,6 +46,12 @@ module.exports = {
transparent: 'transparent',
cyan: '#EBF4F5',
amber: 'oklch(98.7% 0.022 95.277)',
glassWhite: 'rgba(255, 255, 255, 0.5)',
glassMint: 'rgba(248, 255, 254, 0.5)',
glassLavander: 'rgba(243, 241, 255, 0.5)',
blueLight: 'rgba(235, 235, 255, 1)',
offWhite: 'rgba(250, 250, 255, 1)',
'disabled-text': 'rgba(0,0,0, 0.38)',
figmaColors: {
'accent-secondary': 'rgba(62, 170, 175, 1)',
@ -96,6 +102,12 @@ module.exports = {
'light-blue-bg': 'oklch(39.8% 0.07 227.392)',
'disabled-text': 'rgba(255, 255, 255, 0.38)',
glassWhite: 'rgba(30, 30, 30, 0.5)',
glassMint: 'rgba(20, 27, 28, 0.5)',
glassLavander: 'rgba(25, 23, 37, 0.5)',
blueLight: '#343460',
offWhite: 'rgba(30, 30, 50, 1)',
figmaColors: {
'accent-secondary': 'rgba(82, 190, 195, 1)',
'text-disabled': 'rgba(255, 255, 255, 0.38)',

View file

@ -0,0 +1,5 @@
/**
* can be overwritten in saas or ee editions
* */
export const hasAi = 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",
@ -82,6 +82,7 @@
"react-toastify": "^11.0.5",
"react18-json-view": "^0.2.9",
"recharts": "^2.15.3",
"remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.1",
"syncod": "^0.0.1",
"ts-api-utils": "^2.1.0",

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