Kai charting (#3420)
* ui: chart btn * ui: chats list modal fixes, signal editing visually, map charting response * ui: support readable errors for kai * ui: add support for response limiting
This commit is contained in:
parent
24a220bc51
commit
f3f7992c0a
11 changed files with 555 additions and 160 deletions
|
|
@ -1,16 +1,15 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useModal } from 'App/components/Modal';
|
import { useModal } from 'App/components/Modal';
|
||||||
import { MessagesSquare, Trash } from 'lucide-react';
|
|
||||||
import ChatHeader from './components/ChatHeader';
|
import ChatHeader from './components/ChatHeader';
|
||||||
import { PANEL_SIZES } from 'App/constants/panelSizes';
|
import { PANEL_SIZES } from 'App/constants/panelSizes';
|
||||||
import ChatLog from './components/ChatLog';
|
import ChatLog from './components/ChatLog';
|
||||||
import IntroSection from './components/IntroSection';
|
import IntroSection from './components/IntroSection';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { kaiService } from 'App/services';
|
import { kaiService } from 'App/services';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { useHistory, useLocation } from 'react-router-dom';
|
import { useHistory, useLocation } from 'react-router-dom';
|
||||||
|
import ChatsModal from './components/ChatsModal';
|
||||||
|
|
||||||
function KaiChat() {
|
function KaiChat() {
|
||||||
const { userStore, projectsStore } = useStore();
|
const { userStore, projectsStore } = useStore();
|
||||||
|
|
@ -99,7 +98,9 @@ function KaiChat() {
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
|
<div className="w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
|
||||||
<div className={'w-full rounded-lg overflow-hidden border shadow'}>
|
<div
|
||||||
|
className={'w-full rounded-lg overflow-hidden border shadow relative'}
|
||||||
|
>
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
chatTitle={chatTitle}
|
chatTitle={chatTitle}
|
||||||
openChats={openChats}
|
openChats={openChats}
|
||||||
|
|
@ -133,69 +134,4 @@ function KaiChat() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatsModal({
|
|
||||||
onSelect,
|
|
||||||
projectId,
|
|
||||||
}: {
|
|
||||||
onSelect: (threadId: string, title: string) => void;
|
|
||||||
projectId: string;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
data = [],
|
|
||||||
isPending,
|
|
||||||
refetch,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ['kai', 'chats', projectId],
|
|
||||||
queryFn: () => kaiService.getKaiChats(projectId),
|
|
||||||
staleTime: 1000 * 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onDelete = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await kaiService.deleteKaiChat(projectId, 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);
|
export default observer(KaiChat);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import AiService from '@/services/AiService';
|
||||||
export default class KaiService extends AiService {
|
export default class KaiService extends AiService {
|
||||||
getKaiChats = async (
|
getKaiChats = async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
): Promise<{ title: string; thread_id: string }[]> => {
|
): Promise<{ title: string; thread_id: string; datetime: string }[]> => {
|
||||||
const r = await this.client.get(`/kai/${projectId}/chats`);
|
const r = await this.client.get(`/kai/${projectId}/chats`);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
throw new Error('Failed to fetch chats');
|
throw new Error('Failed to fetch chats');
|
||||||
|
|
@ -35,6 +35,7 @@ export default class KaiService extends AiService {
|
||||||
feedback: boolean | null;
|
feedback: boolean | null;
|
||||||
supports_visualization: boolean;
|
supports_visualization: boolean;
|
||||||
chart: string;
|
chart: string;
|
||||||
|
chart_data: string;
|
||||||
}[]
|
}[]
|
||||||
> => {
|
> => {
|
||||||
const r = await this.client.get(`/kai/${projectId}/chats/${threadId}`);
|
const r = await this.client.get(`/kai/${projectId}/chats/${threadId}`);
|
||||||
|
|
@ -79,4 +80,46 @@ export default class KaiService extends AiService {
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getMsgChart = async (
|
||||||
|
messageId: string,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<{ filters: any[]; chart: string; eventsOrder: string }> => {
|
||||||
|
const r = await this.client.get(
|
||||||
|
`/kai/${projectId}/chats/data/${messageId}`,
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error('Failed to fetch chart data');
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
saveChartData = async (
|
||||||
|
messageId: string,
|
||||||
|
projectId: string,
|
||||||
|
chartData: any,
|
||||||
|
) => {
|
||||||
|
const r = await this.client.post(
|
||||||
|
`/kai/${projectId}/chats/data/${messageId}`,
|
||||||
|
{
|
||||||
|
chart_data: JSON.stringify(chartData),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error('Failed to save chart data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await r.json();
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
checkUsage = async (): Promise<{ total: number; used: number }> => {
|
||||||
|
const r = await this.client.get(`/kai/usage`);
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error('Failed to fetch usage');
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
return data;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,25 @@ import { makeAutoObservable, runInAction } from 'mobx';
|
||||||
import { BotChunk, ChatManager } from './SocketManager';
|
import { BotChunk, ChatManager } from './SocketManager';
|
||||||
import { kaiService as aiService, kaiService } from 'App/services';
|
import { kaiService as aiService, kaiService } from 'App/services';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import Widget from 'App/mstore/types/widget';
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
text: string;
|
text: string;
|
||||||
isUser: boolean;
|
isUser: boolean;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
/** filters to get chart */
|
||||||
chart: string;
|
chart: string;
|
||||||
|
/** chart data */
|
||||||
|
chart_data: string;
|
||||||
supports_visualization: boolean;
|
supports_visualization: boolean;
|
||||||
feedback: boolean | null;
|
feedback: boolean | null;
|
||||||
duration: number;
|
duration: number;
|
||||||
}
|
}
|
||||||
export interface SentMessage extends Omit<Message, 'duration' | 'feedback' | 'chart' | 'supports_visualization'> {
|
export interface SentMessage
|
||||||
|
extends Omit<
|
||||||
|
Message,
|
||||||
|
'duration' | 'feedback' | 'chart' | 'supports_visualization'
|
||||||
|
> {
|
||||||
replace: boolean;
|
replace: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22,10 +30,16 @@ class KaiStore {
|
||||||
messages: Array<Message> = [];
|
messages: Array<Message> = [];
|
||||||
queryText = '';
|
queryText = '';
|
||||||
loadingChat = false;
|
loadingChat = false;
|
||||||
replacing = false;
|
replacing: string | null = null;
|
||||||
|
usage = {
|
||||||
|
total: 0,
|
||||||
|
used: 0,
|
||||||
|
percent: 0,
|
||||||
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
|
this.checkUsage();
|
||||||
}
|
}
|
||||||
|
|
||||||
get lastHumanMessage() {
|
get lastHumanMessage() {
|
||||||
|
|
@ -80,9 +94,9 @@ class KaiStore {
|
||||||
this.messages.push(message);
|
this.messages.push(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
editMessage = (text: string) => {
|
editMessage = (text: string, messageId: string) => {
|
||||||
this.setQueryText(text);
|
this.setQueryText(text);
|
||||||
this.setReplacing(true);
|
this.setReplacing(messageId);
|
||||||
};
|
};
|
||||||
|
|
||||||
replaceAtIndex = (message: Message, index: number) => {
|
replaceAtIndex = (message: Message, index: number) => {
|
||||||
|
|
@ -115,6 +129,7 @@ class KaiStore {
|
||||||
feedback: m.feedback,
|
feedback: m.feedback,
|
||||||
chart: m.chart,
|
chart: m.chart,
|
||||||
supports_visualization: m.supports_visualization,
|
supports_visualization: m.supports_visualization,
|
||||||
|
chart_data: m.chart_data,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -137,6 +152,7 @@ class KaiStore {
|
||||||
console.error('No token found');
|
console.error('No token found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.checkUsage();
|
||||||
this.chatManager = new ChatManager({ ...settings, token });
|
this.chatManager = new ChatManager({ ...settings, token });
|
||||||
this.chatManager.setOnMsgHook({
|
this.chatManager.setOnMsgHook({
|
||||||
msgCallback: (msg) => {
|
msgCallback: (msg) => {
|
||||||
|
|
@ -173,7 +189,9 @@ class KaiStore {
|
||||||
feedback: null,
|
feedback: null,
|
||||||
chart: '',
|
chart: '',
|
||||||
supports_visualization: msg.supports_visualization,
|
supports_visualization: msg.supports_visualization,
|
||||||
|
chart_data: '',
|
||||||
};
|
};
|
||||||
|
this.bumpUsage();
|
||||||
this.addMessage(msgObj);
|
this.addMessage(msgObj);
|
||||||
this.setProcessingStage(null);
|
this.setProcessingStage(null);
|
||||||
}
|
}
|
||||||
|
|
@ -187,13 +205,18 @@ class KaiStore {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
setReplacing = (replacing: boolean) => {
|
setReplacing = (replacing: string | null) => {
|
||||||
this.replacing = replacing;
|
this.replacing = replacing;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bumpUsage = () => {
|
||||||
|
this.usage.used += 1;
|
||||||
|
this.usage.percent = (this.usage.used / this.usage.total) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
sendMessage = (message: string) => {
|
sendMessage = (message: string) => {
|
||||||
if (this.chatManager) {
|
if (this.chatManager) {
|
||||||
this.chatManager.sendMessage(message, this.replacing);
|
this.chatManager.sendMessage(message, !!this.replacing);
|
||||||
}
|
}
|
||||||
if (this.replacing) {
|
if (this.replacing) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -219,6 +242,7 @@ class KaiStore {
|
||||||
duration: 0,
|
duration: 0,
|
||||||
supports_visualization: false,
|
supports_visualization: false,
|
||||||
chart: '',
|
chart: '',
|
||||||
|
chart_data: '',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -273,6 +297,64 @@ class KaiStore {
|
||||||
this.chatManager = null;
|
this.chatManager = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getMessageChart = async (msgId: string, projectId: string) => {
|
||||||
|
this.setProcessingStage({
|
||||||
|
content: 'Generating visualization...',
|
||||||
|
stage: 'chart',
|
||||||
|
messageId: msgId,
|
||||||
|
duration: 0,
|
||||||
|
type: 'chunk',
|
||||||
|
supports_visualization: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const filters = await kaiService.getMsgChart(msgId, projectId);
|
||||||
|
const data = {
|
||||||
|
metricId: undefined,
|
||||||
|
dashboardId: undefined,
|
||||||
|
widgetId: undefined,
|
||||||
|
metricOf: undefined,
|
||||||
|
metricType: undefined,
|
||||||
|
metricFormat: undefined,
|
||||||
|
viewType: undefined,
|
||||||
|
name: 'Kai Visualization',
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'Kai Visualization',
|
||||||
|
filter: {
|
||||||
|
eventsOrder: filters.eventsOrder,
|
||||||
|
filters: filters.filters,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const metric = new Widget().fromJson(data);
|
||||||
|
return metric;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
throw new Error('Failed to generate visualization');
|
||||||
|
} finally {
|
||||||
|
this.setProcessingStage(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getParsedChart = (data: string) => {
|
||||||
|
const parsedData = JSON.parse(data);
|
||||||
|
return new Widget().fromJson(parsedData);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkUsage = async () => {
|
||||||
|
try {
|
||||||
|
const { total, used } = await kaiService.checkUsage();
|
||||||
|
this.usage = {
|
||||||
|
total,
|
||||||
|
used,
|
||||||
|
percent: (used / total) * 100,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const kaiStore = new KaiStore();
|
export const kaiStore = new KaiStore();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import io from 'socket.io-client';
|
import io from 'socket.io-client';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
export class ChatManager {
|
export class ChatManager {
|
||||||
socket: ReturnType<typeof io>;
|
socket: ReturnType<typeof io>;
|
||||||
|
|
@ -41,6 +42,9 @@ export class ChatManager {
|
||||||
console.log('Disconnected from server');
|
console.log('Disconnected from server');
|
||||||
});
|
});
|
||||||
socket.on('error', (err) => {
|
socket.on('error', (err) => {
|
||||||
|
if (err.message) {
|
||||||
|
toast.error(err.message);
|
||||||
|
}
|
||||||
console.error('Socket error:', err);
|
console.error('Socket error:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon } from 'UI';
|
import { Icon } from 'UI';
|
||||||
import { MessagesSquare, ArrowLeft } from 'lucide-react';
|
import { MessagesSquare, ArrowLeft } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
function ChatHeader({
|
function ChatHeader({
|
||||||
openChats = () => {},
|
openChats = () => {},
|
||||||
|
|
@ -11,6 +12,7 @@ function ChatHeader({
|
||||||
openChats?: () => void;
|
openChats?: () => void;
|
||||||
chatTitle: string | null;
|
chatTitle: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
|
|
@ -20,17 +22,21 @@ function ChatHeader({
|
||||||
<div className={'flex-1'}>
|
<div className={'flex-1'}>
|
||||||
{goBack ? (
|
{goBack ? (
|
||||||
<div
|
<div
|
||||||
className={'flex items-center gap-2 font-semibold cursor-pointer'}
|
className={
|
||||||
|
'w-fit flex items-center gap-2 font-semibold cursor-pointer'
|
||||||
|
}
|
||||||
onClick={goBack}
|
onClick={goBack}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={14} />
|
<ArrowLeft size={14} />
|
||||||
<div>Back</div>
|
<div>{t('Back')}</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className={'flex items-center gap-2 mx-auto max-w-[80%]'}>
|
<div className={'flex items-center gap-2 mx-auto max-w-[80%]'}>
|
||||||
{chatTitle ? (
|
{chatTitle ? (
|
||||||
<div className="font-semibold text-xl whitespace-nowrap truncate">{chatTitle}</div>
|
<div className="font-semibold text-xl whitespace-nowrap truncate">
|
||||||
|
{chatTitle}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Icon name={'kai_colored'} size={18} />
|
<Icon name={'kai_colored'} size={18} />
|
||||||
|
|
@ -38,14 +44,14 @@ function ChatHeader({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className={'flex-1 justify-end flex items-center gap-2'}>
|
||||||
<div
|
<div
|
||||||
className={
|
className="font-semibold w-fit cursor-pointer flex items-center gap-2"
|
||||||
'font-semibold cursor-pointer flex items-center gap-2 flex-1 justify-end'
|
|
||||||
}
|
|
||||||
onClick={openChats}
|
onClick={openChats}
|
||||||
>
|
>
|
||||||
<MessagesSquare size={14} />
|
<MessagesSquare size={14} />
|
||||||
<div>Chats</div>
|
<div>{t('Chats')}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,109 @@
|
||||||
import React from 'react'
|
import React from 'react';
|
||||||
import { Button, Input } from "antd";
|
import { Button, Input, Tooltip } from 'antd';
|
||||||
import { SendHorizonal, OctagonX } from "lucide-react";
|
import { SendHorizonal, OctagonX } from 'lucide-react';
|
||||||
import { kaiStore } from "../KaiStore";
|
import { kaiStore } from '../KaiStore';
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import Usage from './Usage';
|
||||||
|
|
||||||
function ChatInput({ isLoading, onSubmit, threadId }: { isLoading?: boolean, onSubmit: (str: string) => void, threadId: string }) {
|
function ChatInput({
|
||||||
const inputRef = React.useRef<Input>(null);
|
isLoading,
|
||||||
|
onSubmit,
|
||||||
|
threadId,
|
||||||
|
}: {
|
||||||
|
isLoading?: boolean;
|
||||||
|
onSubmit: (str: string) => void;
|
||||||
|
threadId: string;
|
||||||
|
}) {
|
||||||
|
const inputRef = React.useRef<typeof Input>(null);
|
||||||
|
const usage = kaiStore.usage;
|
||||||
|
const limited = usage.percent >= 100;
|
||||||
const inputValue = kaiStore.queryText;
|
const inputValue = kaiStore.queryText;
|
||||||
const isProcessing = kaiStore.processingStage !== null
|
const isProcessing = kaiStore.processingStage !== null;
|
||||||
const setInputValue = (text: string) => {
|
const setInputValue = (text: string) => {
|
||||||
kaiStore.setQueryText(text)
|
kaiStore.setQueryText(text);
|
||||||
}
|
};
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
|
if (limited) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isProcessing) {
|
if (isProcessing) {
|
||||||
const settings = { projectId: '2325', userId: '0', threadId, };
|
const settings = { projectId: '2325', userId: '0', threadId };
|
||||||
void kaiStore.cancelGeneration(settings)
|
void kaiStore.cancelGeneration(settings);
|
||||||
} else {
|
} else {
|
||||||
if (inputValue.length > 0) {
|
if (inputValue.length > 0) {
|
||||||
onSubmit(inputValue)
|
onSubmit(inputValue);
|
||||||
setInputValue('')
|
setInputValue('');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelReplace = () => {
|
||||||
|
setInputValue('');
|
||||||
|
kaiStore.setReplacing(null);
|
||||||
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
inputRef.current.focus()
|
inputRef.current.focus();
|
||||||
}
|
}
|
||||||
}, [inputValue])
|
}, [inputValue]);
|
||||||
|
|
||||||
|
const isReplacing = kaiStore.replacing !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
onPressEnter={submit}
|
onPressEnter={submit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
cancelReplace();
|
||||||
|
}
|
||||||
|
}}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder={'Ask anything about your product and users...'}
|
placeholder={'Ask anything about your product and users...'}
|
||||||
size={'large'}
|
size={'large'}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
suffix={
|
suffix={
|
||||||
|
<>
|
||||||
|
{isReplacing ? (
|
||||||
|
<Tooltip title={'Cancel Editing'}>
|
||||||
|
<Button
|
||||||
|
onClick={cancelReplace}
|
||||||
|
icon={<OctagonX size={16} />}
|
||||||
|
type={'text'}
|
||||||
|
size={'small'}
|
||||||
|
shape={'circle'}
|
||||||
|
disabled={limited}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
<Tooltip title={'Send message'}>
|
||||||
<Button
|
<Button
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
icon={isProcessing ? <OctagonX size={16} /> : <SendHorizonal size={16} />}
|
disabled={limited}
|
||||||
|
icon={
|
||||||
|
isProcessing ? (
|
||||||
|
<OctagonX size={16} />
|
||||||
|
) : (
|
||||||
|
<SendHorizonal size={16} />
|
||||||
|
)
|
||||||
|
}
|
||||||
type={'text'}
|
type={'text'}
|
||||||
size={'small'}
|
size={'small'}
|
||||||
shape={'circle'}
|
shape={'circle'}
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
<div className="absolute ml-1 top-2 -right-11">
|
||||||
|
<Usage />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default observer(ChatInput)
|
export default observer(ChatInput);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ChatInput from './ChatInput';
|
import ChatInput from './ChatInput';
|
||||||
import { ChatMsg, ChatNotice } from './ChatMsg';
|
import ChatMsg, { ChatNotice } from './ChatMsg';
|
||||||
import { Loader } from 'UI';
|
import { Loader } from 'UI';
|
||||||
import { kaiStore } from '../KaiStore';
|
import { kaiStore } from '../KaiStore';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
@ -61,17 +61,14 @@ function ChatLog({
|
||||||
>
|
>
|
||||||
<div className={'flex flex-col gap-4 w-2/3 min-h-max'}>
|
<div className={'flex flex-col gap-4 w-2/3 min-h-max'}>
|
||||||
{messages.map((msg, index) => (
|
{messages.map((msg, index) => (
|
||||||
|
<React.Fragment key={msg.messageId ?? index}>
|
||||||
<ChatMsg
|
<ChatMsg
|
||||||
key={index}
|
|
||||||
text={msg.text}
|
|
||||||
isUser={msg.isUser}
|
|
||||||
userName={userLetter}
|
userName={userLetter}
|
||||||
messageId={msg.messageId}
|
|
||||||
duration={msg.duration}
|
|
||||||
feedback={msg.feedback}
|
|
||||||
siteId={projectId}
|
siteId={projectId}
|
||||||
|
message={msg}
|
||||||
canEdit={processingStage === null && msg.isUser && index === lastHumanMsgInd}
|
canEdit={processingStage === null && msg.isUser && index === lastHumanMsgInd}
|
||||||
/>
|
/>
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
{processingStage ? (
|
{processingStage ? (
|
||||||
<ChatNotice
|
<ChatNotice
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon, CopyButton } from 'UI';
|
import { Icon, CopyButton } from 'UI';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import Markdown from 'react-markdown';
|
import Markdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
@ -10,36 +11,49 @@ import {
|
||||||
ListRestart,
|
ListRestart,
|
||||||
FileDown,
|
FileDown,
|
||||||
Clock,
|
Clock,
|
||||||
|
ChartLine,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button, Tooltip } from 'antd';
|
import { Button, Tooltip } from 'antd';
|
||||||
import { kaiStore } from '../KaiStore';
|
import { kaiStore, Message } from '../KaiStore';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { durationFormatted } from 'App/date';
|
import { durationFormatted } from 'App/date';
|
||||||
|
import WidgetChart from '@/components/Dashboard/components/WidgetChart';
|
||||||
|
import Widget from 'App/mstore/types/widget';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function ChatMsg({
|
function ChatMsg({
|
||||||
|
userName,
|
||||||
|
siteId,
|
||||||
|
canEdit,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
message: Message;
|
||||||
|
userName?: string;
|
||||||
|
canEdit?: boolean;
|
||||||
|
siteId: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [metric, setMetric] = React.useState<Widget | null>(null);
|
||||||
|
const [loadingChart, setLoadingChart] = React.useState(false);
|
||||||
|
const {
|
||||||
text,
|
text,
|
||||||
isUser,
|
isUser,
|
||||||
userName,
|
|
||||||
messageId,
|
messageId,
|
||||||
duration,
|
duration,
|
||||||
feedback,
|
feedback,
|
||||||
siteId,
|
supports_visualization,
|
||||||
canEdit,
|
chart_data,
|
||||||
}: {
|
} = message;
|
||||||
text: string;
|
const isEditing = kaiStore.replacing && messageId === kaiStore.replacing;
|
||||||
isUser: boolean;
|
|
||||||
messageId: string;
|
|
||||||
userName?: string;
|
|
||||||
duration?: number;
|
|
||||||
feedback: boolean | null;
|
|
||||||
siteId: string;
|
|
||||||
canEdit?: boolean;
|
|
||||||
}) {
|
|
||||||
const [isProcessing, setIsProcessing] = React.useState(false);
|
const [isProcessing, setIsProcessing] = React.useState(false);
|
||||||
const bodyRef = React.useRef<HTMLDivElement>(null);
|
const bodyRef = React.useRef<HTMLDivElement>(null);
|
||||||
const onRetry = () => {
|
const onEdit = () => {
|
||||||
kaiStore.editMessage(text);
|
kaiStore.editMessage(text, messageId);
|
||||||
};
|
};
|
||||||
|
const onCancelEdit = () => {
|
||||||
|
kaiStore.setQueryText('');
|
||||||
|
kaiStore.setReplacing(null);
|
||||||
|
}
|
||||||
const onFeedback = (feedback: 'like' | 'dislike', messageId: string) => {
|
const onFeedback = (feedback: 'like' | 'dislike', messageId: string) => {
|
||||||
kaiStore.sendMsgFeedback(feedback, messageId, siteId);
|
kaiStore.sendMsgFeedback(feedback, messageId, siteId);
|
||||||
};
|
};
|
||||||
|
|
@ -74,6 +88,25 @@ export function ChatMsg({
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (chart_data) {
|
||||||
|
const metric = kaiStore.getParsedChart(chart_data);
|
||||||
|
setMetric(metric);
|
||||||
|
}
|
||||||
|
}, [chart_data]);
|
||||||
|
|
||||||
|
const getChart = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingChart(true);
|
||||||
|
const metric = await kaiStore.getMessageChart(messageId, siteId);
|
||||||
|
setMetric(metric);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e.message);
|
||||||
|
} finally {
|
||||||
|
setLoadingChart(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -84,7 +117,7 @@ export function ChatMsg({
|
||||||
{isUser ? (
|
{isUser ? (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'rounded-full bg-main text-white min-w-8 min-h-8 flex items-center justify-center sticky top-0'
|
'rounded-full bg-main text-white min-w-8 min-h-8 flex items-center justify-center sticky top-0 shadow'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className={'font-semibold'}>{userName}</span>
|
<span className={'font-semibold'}>{userName}</span>
|
||||||
|
|
@ -92,28 +125,54 @@ export function ChatMsg({
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'rounded-full bg-white shadow min-w-8 min-h-8 flex items-center justify-center sticky top-0'
|
'rounded-full bg-gray-lightest shadow min-w-8 min-h-8 flex items-center justify-center sticky top-0'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icon name={'kai_colored'} size={18} />
|
<Icon name={'kai_colored'} size={18} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={'mt-1 flex flex-col'}>
|
<div className={'mt-1 flex flex-col'}>
|
||||||
<div className="markdown-body" data-openreplay-obscured ref={bodyRef}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'markdown-body',
|
||||||
|
isEditing ? 'border-l border-l-main pl-2' : '',
|
||||||
|
)}
|
||||||
|
data-openreplay-obscured
|
||||||
|
ref={bodyRef}
|
||||||
|
>
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
|
{metric ? (
|
||||||
|
<div className="p-2 border-gray-light rounded-lg shadow">
|
||||||
|
<WidgetChart metric={metric} isPreview />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{isUser ? (
|
{isUser ? (
|
||||||
canEdit ? (
|
<>
|
||||||
<div
|
<div
|
||||||
onClick={onRetry}
|
onClick={onEdit}
|
||||||
className={
|
className={cn(
|
||||||
'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'
|
'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',
|
||||||
|
canEdit && !isEditing ? '' : 'hidden',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ListRestart size={16} />
|
<ListRestart size={16} />
|
||||||
<div>Edit</div>
|
<div>{t('Edit')}</div>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
<div
|
||||||
|
onClick={onCancelEdit}
|
||||||
|
className={cn(
|
||||||
|
'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',
|
||||||
|
isEditing ? '' : 'hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>{t('Cancel')}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className={'flex items-center gap-2'}>
|
<div className={'flex items-center gap-2'}>
|
||||||
{duration ? <MsgDuration duration={duration} /> : null}
|
{duration ? <MsgDuration duration={duration} /> : null}
|
||||||
|
|
@ -132,6 +191,15 @@ export function ChatMsg({
|
||||||
>
|
>
|
||||||
<ThumbsDown size={16} />
|
<ThumbsDown size={16} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{supports_visualization ? (
|
||||||
|
<IconButton
|
||||||
|
tooltip="Visualize this answer"
|
||||||
|
onClick={getChart}
|
||||||
|
processing={loadingChart}
|
||||||
|
>
|
||||||
|
<ChartLine size={16} />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
<CopyButton
|
<CopyButton
|
||||||
getHtml={() => bodyRef.current?.innerHTML}
|
getHtml={() => bodyRef.current?.innerHTML}
|
||||||
content={text}
|
content={text}
|
||||||
|
|
@ -215,3 +283,5 @@ function MsgDuration({ duration }: { duration: number }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default observer(ChatMsg);
|
||||||
|
|
|
||||||
136
frontend/app/components/Kai/components/ChatsModal.tsx
Normal file
136
frontend/app/components/Kai/components/ChatsModal.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { splitByDate } from '../utils';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { MessagesSquare, Trash } from 'lucide-react';
|
||||||
|
import { kaiService } from 'App/services';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { kaiStore } from '../KaiStore';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
|
function ChatsModal({
|
||||||
|
onSelect,
|
||||||
|
projectId,
|
||||||
|
}: {
|
||||||
|
onSelect: (threadId: string, title: string) => void;
|
||||||
|
projectId: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { usage } = kaiStore;
|
||||||
|
const {
|
||||||
|
data = [],
|
||||||
|
isPending,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['kai', 'chats', projectId],
|
||||||
|
queryFn: () => kaiService.getKaiChats(projectId),
|
||||||
|
staleTime: 1000 * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
kaiStore.checkUsage();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const datedCollections = React.useMemo(() => {
|
||||||
|
return data.length ? splitByDate(data) : [];
|
||||||
|
}, [data.length]);
|
||||||
|
|
||||||
|
const onDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await kaiService.deleteKaiChat(projectId, 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>{t('Chats')}</span>
|
||||||
|
</div>
|
||||||
|
{usage.percent > 80 ? (
|
||||||
|
<div className="text-red text-sm">
|
||||||
|
{t('You have used {{used}} out of {{total}} daily requests', {
|
||||||
|
used: usage.used,
|
||||||
|
total: usage.total,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isPending ? (
|
||||||
|
<div className="animate-pulse text-disabled-text">{t('Loading chats')}...</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-y-auto flex flex-col gap-2">
|
||||||
|
{datedCollections.map((col) => (
|
||||||
|
<ChatCollection
|
||||||
|
data={col.entries}
|
||||||
|
date={col.date}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatCollection({
|
||||||
|
data,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
date,
|
||||||
|
}: {
|
||||||
|
data: { title: string; thread_id: string }[];
|
||||||
|
onSelect: (threadId: string, title: string) => void;
|
||||||
|
onDelete: (threadId: string) => void;
|
||||||
|
date: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-disabled-text">{date}</div>
|
||||||
|
<ChatsList data={data} onSelect={onSelect} onDelete={onDelete} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatsList({
|
||||||
|
data,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
data: { title: string; thread_id: string }[];
|
||||||
|
onSelect: (threadId: string, title: string) => void;
|
||||||
|
onDelete: (threadId: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 -mx-4 px-4">
|
||||||
|
{data.map((chat) => (
|
||||||
|
<div
|
||||||
|
key={chat.thread_id}
|
||||||
|
className="flex items-center relative group min-h-7"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ width: 270 - 28 - 4 }}
|
||||||
|
className="rounded-l pl-2 min-h-7 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 min-h-7 h-full px-2 flex items-center group-hover:bg-active-blue"
|
||||||
|
>
|
||||||
|
<Trash size={14} className="text-disabled-text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(ChatsModal);
|
||||||
31
frontend/app/components/Kai/components/Usage.tsx
Normal file
31
frontend/app/components/Kai/components/Usage.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { kaiStore } from '../KaiStore';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { Progress, Tooltip } from 'antd';
|
||||||
|
const getUsageColor = (percent: number) => {
|
||||||
|
return 'disabled-text';
|
||||||
|
};
|
||||||
|
|
||||||
|
function Usage() {
|
||||||
|
const { usage } = kaiStore;
|
||||||
|
const color = getUsageColor(usage.percent);
|
||||||
|
|
||||||
|
if (usage.total === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Tooltip title={`Daily response limit (${usage.used}/${usage.total})`}>
|
||||||
|
<Progress
|
||||||
|
percent={usage.percent}
|
||||||
|
strokeColor={usage.percent < 99 ? 'var(--color-main)' : 'var(--color-red)'}
|
||||||
|
showInfo={false}
|
||||||
|
type="circle"
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(Usage);
|
||||||
36
frontend/app/components/Kai/utils.ts
Normal file
36
frontend/app/components/Kai/utils.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
type DatedEntry = {
|
||||||
|
date: string;
|
||||||
|
entries: { datetime: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitByDate(entries: { datetime: string }[]) {
|
||||||
|
const today = DateTime.now().startOf('day');
|
||||||
|
const yesterday = today.minus({ days: 1 });
|
||||||
|
|
||||||
|
const result: DatedEntry[] = [
|
||||||
|
{ date: 'Today', entries: [] },
|
||||||
|
{ date: 'Yesterday', entries: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
entries.forEach((ent) => {
|
||||||
|
const entryDate = DateTime.fromISO(ent.datetime).startOf('day');
|
||||||
|
|
||||||
|
if (entryDate.toMillis() === today.toMillis()) {
|
||||||
|
result[0].entries.push(ent);
|
||||||
|
} else if (entryDate.toMillis() === yesterday.toMillis()) {
|
||||||
|
result[1].entries.push(ent);
|
||||||
|
} else {
|
||||||
|
const date = entryDate.toFormat('dd LLL, yyyy')
|
||||||
|
const existingEntry = result.find((r) => r.date === date);
|
||||||
|
if (existingEntry) {
|
||||||
|
existingEntry.entries.push(ent);
|
||||||
|
} else {
|
||||||
|
result.push({ entries: [ent], date });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue