feat(ai): add ai chat to dashboard
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import Chat from '@/components/chat/chat';
|
||||
import { db, getOrganizationBySlug } from '@openpanel/db';
|
||||
import type { UIMessage } from 'ai';
|
||||
|
||||
export default async function ChatPage({
|
||||
params,
|
||||
}: {
|
||||
params: { organizationSlug: string; projectId: string };
|
||||
}) {
|
||||
const { projectId } = await params;
|
||||
const [organization, chat] = await Promise.all([
|
||||
getOrganizationBySlug(params.organizationSlug),
|
||||
db.chat.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const messages = ((chat?.messages as UIMessage[]) || []).slice(-10);
|
||||
return (
|
||||
<Chat
|
||||
projectId={projectId}
|
||||
initialMessages={messages}
|
||||
organization={organization}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useSelectedLayoutSegments } from 'next/navigation';
|
||||
|
||||
const NOT_MIGRATED_PAGES = ['reports'];
|
||||
@@ -16,6 +17,13 @@ export default function LayoutContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-20 transition-all max-lg:mt-12 lg:pl-72">{children}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'pb-20 transition-all max-lg:mt-12 lg:pl-72',
|
||||
segments.includes('chat') && 'pb-0',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PlusIcon,
|
||||
ScanEyeIcon,
|
||||
ServerIcon,
|
||||
SparklesIcon,
|
||||
UsersIcon,
|
||||
WallpaperIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -23,6 +24,7 @@ import { usePathname } from 'next/navigation';
|
||||
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { CommandShortcut } from '@/components/ui/command';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { IServiceDashboards, IServiceOrganization } from '@openpanel/db';
|
||||
import { differenceInDays, format } from 'date-fns';
|
||||
@@ -174,15 +176,25 @@ export default function LayoutMenu({
|
||||
</div>
|
||||
</ProjectLink>
|
||||
)}
|
||||
<ProjectLink
|
||||
href={'/chat'}
|
||||
className={cn('rounded p-2 row gap-2 hover:bg-def-200 items-center')}
|
||||
>
|
||||
<SparklesIcon size={20} />
|
||||
<div className="flex-1 col gap-1">
|
||||
<div className="font-medium">Ask AI</div>
|
||||
</div>
|
||||
<CommandShortcut>⌘K</CommandShortcut>
|
||||
</ProjectLink>
|
||||
<ProjectLink
|
||||
href={'/reports'}
|
||||
className={cn('rounded p-2 row gap-2 hover:bg-def-200')}
|
||||
className={cn('rounded p-2 row gap-2 hover:bg-def-200 items-center')}
|
||||
>
|
||||
<ChartLineIcon size={20} />
|
||||
<div className="flex-1 col gap-1">
|
||||
<div className="font-medium">Create report</div>
|
||||
</div>
|
||||
<PlusIcon size={16} className="text-muted-foreground" />
|
||||
<CommandShortcut>⌘J</CommandShortcut>
|
||||
</ProjectLink>
|
||||
</div>
|
||||
<LinkWithIcon icon={WallpaperIcon} label="Overview" href={'/'} />
|
||||
|
||||
@@ -7,8 +7,10 @@ import { ReportInterval } from '@/components/report/ReportInterval';
|
||||
import { ReportLineType } from '@/components/report/ReportLineType';
|
||||
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
|
||||
import {
|
||||
changeChartType,
|
||||
changeDateRanges,
|
||||
changeEndDate,
|
||||
changeInterval,
|
||||
changeStartDate,
|
||||
ready,
|
||||
reset,
|
||||
@@ -74,7 +76,13 @@ export default function ReportEditor({
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<div className="col-span-4 grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
<ReportChartType className="min-w-0 flex-1" />
|
||||
<ReportChartType
|
||||
className="min-w-0 flex-1"
|
||||
onChange={(type) => {
|
||||
dispatch(changeChartType(type));
|
||||
}}
|
||||
value={report.chartType}
|
||||
/>
|
||||
<TimeWindowPicker
|
||||
className="min-w-0 flex-1"
|
||||
onChange={(value) => {
|
||||
@@ -90,7 +98,13 @@ export default function ReportEditor({
|
||||
endDate={report.endDate}
|
||||
startDate={report.startDate}
|
||||
/>
|
||||
<ReportInterval className="min-w-0 flex-1" />
|
||||
<ReportInterval
|
||||
className="min-w-0 flex-1"
|
||||
interval={report.interval}
|
||||
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
|
||||
range={report.range}
|
||||
chartType={report.chartType}
|
||||
/>
|
||||
<ReportLineType className="min-w-0 flex-1" />
|
||||
</div>
|
||||
<div className="col-start-2 row-start-1 text-right md:col-start-6">
|
||||
|
||||
@@ -5,6 +5,7 @@ import Providers from './providers';
|
||||
|
||||
import '@/styles/globals.css';
|
||||
import 'flag-icons/css/flag-icons.min.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
import { GeistMono } from 'geist/font/mono';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
export const dynamic = 'static';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
id: process.env.NEXT_PUBLIC_DASHBOARD_URL,
|
||||
|
||||
@@ -54,34 +54,34 @@ function AllProviders({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
disableTransitionOnChange
|
||||
defaultTheme="system"
|
||||
>
|
||||
{process.env.NEXT_PUBLIC_OP_CLIENT_ID && (
|
||||
<OpenPanelComponent
|
||||
clientId={process.env.NEXT_PUBLIC_OP_CLIENT_ID}
|
||||
trackScreenViews
|
||||
trackOutgoingLinks
|
||||
trackAttributes
|
||||
/>
|
||||
)}
|
||||
<ReduxProvider store={storeRef.current}>
|
||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsAdapter>
|
||||
<NuqsAdapter>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
disableTransitionOnChange
|
||||
defaultTheme="system"
|
||||
>
|
||||
{process.env.NEXT_PUBLIC_OP_CLIENT_ID && (
|
||||
<OpenPanelComponent
|
||||
clientId={process.env.NEXT_PUBLIC_OP_CLIENT_ID}
|
||||
trackScreenViews
|
||||
trackOutgoingLinks
|
||||
trackAttributes
|
||||
/>
|
||||
)}
|
||||
<ReduxProvider store={storeRef.current}>
|
||||
<api.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
{children}
|
||||
<NotificationProvider />
|
||||
<Toaster />
|
||||
<ModalProvider />
|
||||
</TooltipProvider>
|
||||
</NuqsAdapter>
|
||||
</QueryClientProvider>
|
||||
</api.Provider>
|
||||
</ReduxProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</api.Provider>
|
||||
</ReduxProvider>
|
||||
</ThemeProvider>
|
||||
</NuqsAdapter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
70
apps/dashboard/src/components/chat/chat-form.tsx
Normal file
70
apps/dashboard/src/components/chat/chat-form.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { useChat } from '@ai-sdk/react';
|
||||
import { useLocalStorage } from 'usehooks-ts';
|
||||
import { Button } from '../ui/button';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
type Props = Pick<
|
||||
ReturnType<typeof useChat>,
|
||||
'handleSubmit' | 'handleInputChange' | 'input' | 'append'
|
||||
> & {
|
||||
projectId: string;
|
||||
isLimited: boolean;
|
||||
};
|
||||
|
||||
export function ChatForm({
|
||||
handleSubmit: handleSubmitProp,
|
||||
input,
|
||||
handleInputChange,
|
||||
append,
|
||||
projectId,
|
||||
isLimited,
|
||||
}: Props) {
|
||||
const [quickActions, setQuickActions] = useLocalStorage<string[]>(
|
||||
`chat-quick-actions:${projectId}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
handleSubmitProp(e);
|
||||
setQuickActions([input, ...quickActions].slice(0, 5));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-def-100 to-def-100/50 backdrop-blur-sm z-20">
|
||||
<ScrollArea orientation="horizontal">
|
||||
<div className="row gap-2 px-4">
|
||||
{quickActions.map((q) => (
|
||||
<Button
|
||||
disabled={isLimited}
|
||||
key={q}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
append({
|
||||
role: 'user',
|
||||
content: q,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{q}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<form onSubmit={handleSubmit} className="p-4 pt-2">
|
||||
<input
|
||||
disabled={isLimited}
|
||||
className={cn(
|
||||
'w-full h-12 px-4 outline-none border border-border text-foreground rounded-md font-mono placeholder:text-foreground/50 bg-background/50',
|
||||
isLimited && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
value={input}
|
||||
placeholder="Ask me anything..."
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
apps/dashboard/src/components/chat/chat-message.tsx
Normal file
144
apps/dashboard/src/components/chat/chat-message.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Markdown } from '@/components/markdown';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { zChartInputAI } from '@openpanel/validation';
|
||||
import type { UIMessage } from 'ai';
|
||||
import { Loader2Icon, UserIcon } from 'lucide-react';
|
||||
import { Fragment, memo } from 'react';
|
||||
import { Card } from '../card';
|
||||
import { LogoSquare } from '../logo';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import Syntax from '../syntax';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '../ui/accordion';
|
||||
import { ChatReport } from './chat-report';
|
||||
|
||||
export const ChatMessage = memo(
|
||||
({
|
||||
message,
|
||||
isLast,
|
||||
isStreaming,
|
||||
debug,
|
||||
}: {
|
||||
message: UIMessage;
|
||||
isLast: boolean;
|
||||
isStreaming: boolean;
|
||||
debug: boolean;
|
||||
}) => {
|
||||
const showIsStreaming = isLast && isStreaming;
|
||||
return (
|
||||
<div className="max-w-xl w-full">
|
||||
<div className="row">
|
||||
<div className="w-8 shrink-0">
|
||||
<div className="size-6 relative">
|
||||
{message.role === 'assistant' ? (
|
||||
<LogoSquare className="size-full rounded-full" />
|
||||
) : (
|
||||
<div className="size-full bg-black text-white rounded-full center-center">
|
||||
<UserIcon className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 bg-background rounded-full center-center opacity-0',
|
||||
showIsStreaming && 'opacity-100',
|
||||
)}
|
||||
>
|
||||
<Loader2Icon className="size-4 animate-spin text-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col gap-4 flex-1">
|
||||
{message.parts.map((p, index) => {
|
||||
const key = index.toString() + p.type;
|
||||
const isToolInvocation = p.type === 'tool-invocation';
|
||||
|
||||
if (p.type === 'step-start') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isToolInvocation && p.type !== 'text') {
|
||||
return <Debug enabled={debug} json={p} />;
|
||||
}
|
||||
|
||||
if (p.type === 'text') {
|
||||
return (
|
||||
<div className="prose dark:prose-invert prose-sm" key={key}>
|
||||
<Markdown>{p.text}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isToolInvocation && p.toolInvocation.state === 'result') {
|
||||
const { result } = p.toolInvocation;
|
||||
|
||||
if (result.type === 'report') {
|
||||
const report = zChartInputAI.safeParse(result.report);
|
||||
if (report.success) {
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<Debug json={result} enabled={debug} />
|
||||
<ChatReport report={report.data} lazy={!isLast} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Debug
|
||||
key={key}
|
||||
json={p.toolInvocation.result}
|
||||
enabled={debug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{showIsStreaming && (
|
||||
<div className="w-full col gap-2">
|
||||
<Skeleton className="w-3/5 h-4" />
|
||||
<Skeleton className="w-4/5 h-4" />
|
||||
<Skeleton className="w-2/5 h-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="w-full shrink-0 pl-8 mt-4">
|
||||
<div className="h-px bg-border" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function Debug({ enabled, json }: { enabled?: boolean; json?: any }) {
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion type="single" collapsible>
|
||||
<Card>
|
||||
<AccordionItem value={'json'}>
|
||||
<AccordionTrigger className="text-left p-4 py-2 w-full font-medium font-mono row items-center">
|
||||
<span className="flex-1">Show JSON result</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-2">
|
||||
<Syntax
|
||||
wrapLines
|
||||
language="json"
|
||||
code={JSON.stringify(json, null, 2)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Card>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
84
apps/dashboard/src/components/chat/chat-messages.tsx
Normal file
84
apps/dashboard/src/components/chat/chat-messages.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useScrollAnchor } from '@/hooks/use-scroll-anchor';
|
||||
import type { IServiceOrganization, Organization } from '@openpanel/db';
|
||||
import type { UIMessage } from 'ai';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { ProjectLink } from '../links';
|
||||
import { Markdown } from '../markdown';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
|
||||
import { Button, LinkButton } from '../ui/button';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { ChatMessage } from './chat-message';
|
||||
|
||||
export function ChatMessages({
|
||||
messages,
|
||||
debug,
|
||||
status,
|
||||
isLimited,
|
||||
}: {
|
||||
messages: UIMessage[];
|
||||
debug: boolean;
|
||||
status: 'submitted' | 'streaming' | 'ready' | 'error';
|
||||
isLimited: boolean;
|
||||
}) {
|
||||
const { messagesRef, scrollRef, visibilityRef, scrollToBottom } =
|
||||
useScrollAnchor();
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage?.role === 'user') {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full" ref={scrollRef}>
|
||||
<div ref={messagesRef} className="p-8 col gap-2">
|
||||
{messages.map((m, index) => {
|
||||
return (
|
||||
<ChatMessage
|
||||
key={m.id}
|
||||
message={m}
|
||||
isStreaming={status === 'streaming'}
|
||||
isLast={index === messages.length - 1}
|
||||
debug={debug}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{status === 'submitted' && (
|
||||
<div className="card p-4 center-center max-w-xl pl-8">
|
||||
<Loader2Icon className="w-4 h-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{isLimited && (
|
||||
<div className="max-w-xl pl-8 mt-8">
|
||||
<Alert variant={'warning'}>
|
||||
<AlertTitle>Upgrade your account</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
To keep using this feature you need to upgrade your account.
|
||||
</p>
|
||||
<p>
|
||||
<ProjectLink
|
||||
href="/settings/organization?tab=billing"
|
||||
className="font-medium underline"
|
||||
>
|
||||
Visit Billing
|
||||
</ProjectLink>{' '}
|
||||
to upgrade.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-20 p-4 w-full" />
|
||||
<div className="w-full h-px" ref={visibilityRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
90
apps/dashboard/src/components/chat/chat-report.tsx
Normal file
90
apps/dashboard/src/components/chat/chat-report.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { pushModal } from '@/modals';
|
||||
import type {
|
||||
IChartInputAi,
|
||||
IChartRange,
|
||||
IChartType,
|
||||
IInterval,
|
||||
} from '@openpanel/validation';
|
||||
import { endOfDay, startOfDay } from 'date-fns';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ReportChart } from '../report-chart';
|
||||
import { ReportChartType } from '../report/ReportChartType';
|
||||
import { ReportInterval } from '../report/ReportInterval';
|
||||
import { TimeWindowPicker } from '../time-window-picker';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export function ChatReport({
|
||||
lazy,
|
||||
...props
|
||||
}: { report: IChartInputAi; lazy: boolean }) {
|
||||
const [chartType, setChartType] = useState<IChartType>(
|
||||
props.report.chartType,
|
||||
);
|
||||
const [startDate, setStartDate] = useState<string>(props.report.startDate);
|
||||
const [endDate, setEndDate] = useState<string>(props.report.endDate);
|
||||
const [range, setRange] = useState<IChartRange>(props.report.range);
|
||||
const [interval, setInterval] = useState<IInterval>(props.report.interval);
|
||||
const report = {
|
||||
...props.report,
|
||||
lineType: 'linear' as const,
|
||||
chartType,
|
||||
startDate: range === 'custom' ? startDate : null,
|
||||
endDate: range === 'custom' ? endDate : null,
|
||||
range,
|
||||
interval,
|
||||
};
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="text-center text-sm font-mono font-medium pt-4">
|
||||
{props.report.name}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<ReportChart lazy={lazy} report={report} />
|
||||
</div>
|
||||
<div className="row justify-between gap-1 border-t border-border p-2">
|
||||
<div className="col md:row gap-1">
|
||||
<TimeWindowPicker
|
||||
className="min-w-0"
|
||||
onChange={setRange}
|
||||
value={report.range}
|
||||
onStartDateChange={(date) =>
|
||||
setStartDate(startOfDay(date).toISOString())
|
||||
}
|
||||
onEndDateChange={(date) => setEndDate(endOfDay(date).toISOString())}
|
||||
endDate={report.endDate}
|
||||
startDate={report.startDate}
|
||||
/>
|
||||
<ReportInterval
|
||||
className="min-w-0"
|
||||
interval={interval}
|
||||
range={range}
|
||||
chartType={chartType}
|
||||
onChange={setInterval}
|
||||
/>
|
||||
<ReportChartType
|
||||
value={chartType}
|
||||
onChange={(type) => {
|
||||
setChartType(type);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={SaveIcon}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
pushModal('SaveReport', {
|
||||
report,
|
||||
disableRedirect: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Save report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
apps/dashboard/src/components/chat/chat.tsx
Normal file
76
apps/dashboard/src/components/chat/chat.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { ChatForm } from '@/components/chat/chat-form';
|
||||
import { ChatMessages } from '@/components/chat/chat-messages';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import type { IServiceOrganization } from '@openpanel/db';
|
||||
import type { UIMessage } from 'ai';
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const getErrorMessage = (error: Error) => {
|
||||
try {
|
||||
const parsed = JSON.parse(error.message);
|
||||
return parsed.message || error.message;
|
||||
} catch (e) {
|
||||
return error.message;
|
||||
}
|
||||
};
|
||||
export default function Chat({
|
||||
initialMessages,
|
||||
projectId,
|
||||
organization,
|
||||
}: {
|
||||
initialMessages?: UIMessage[];
|
||||
projectId: string;
|
||||
organization: IServiceOrganization;
|
||||
}) {
|
||||
const { messages, input, handleInputChange, handleSubmit, status, append } =
|
||||
useChat({
|
||||
onError(error) {
|
||||
const message = getErrorMessage(error);
|
||||
toast.error(message);
|
||||
},
|
||||
api: `${process.env.NEXT_PUBLIC_API_URL}/ai/chat?projectId=${projectId}`,
|
||||
initialMessages: (initialMessages ?? []) as any,
|
||||
fetch: (url, options) => {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
mode: 'cors',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [debug, setDebug] = useQueryState(
|
||||
'debug',
|
||||
parseAsBoolean.withDefault(false),
|
||||
);
|
||||
const isLimited = Boolean(
|
||||
messages.length > 5 &&
|
||||
(organization.isCanceled ||
|
||||
organization.isTrial ||
|
||||
organization.isWillBeCanceled ||
|
||||
organization.isExceeded ||
|
||||
organization.isExpired),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full col relative">
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
debug={debug}
|
||||
status={status}
|
||||
isLimited={isLimited}
|
||||
/>
|
||||
<ChatForm
|
||||
handleSubmit={handleSubmit}
|
||||
input={input}
|
||||
handleInputChange={handleInputChange}
|
||||
append={append}
|
||||
projectId={projectId}
|
||||
isLimited={isLimited}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
apps/dashboard/src/components/markdown.tsx
Normal file
26
apps/dashboard/src/components/markdown.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { memo } from 'react';
|
||||
import ReactMarkdown, { type Options } from 'react-markdown';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkHighlight from 'remark-highlight';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
export const Markdown = memo<Options>(
|
||||
(props) => (
|
||||
<ReactMarkdown
|
||||
{...props}
|
||||
remarkPlugins={[remarkParse, remarkHighlight, remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeKatex, remarkRehype]}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
'className' in prevProps &&
|
||||
'className' in nextProps &&
|
||||
prevProps.className === nextProps.className,
|
||||
);
|
||||
|
||||
Markdown.displayName = 'Markdown';
|
||||
@@ -39,6 +39,7 @@ type ReportChartContextProviderProps = ReportChartContextType & {
|
||||
|
||||
export type ReportChartProps = Partial<ReportChartContextType> & {
|
||||
report: IChartInput;
|
||||
lazy?: boolean;
|
||||
};
|
||||
|
||||
const context = createContext<ReportChartContextType | null>(null);
|
||||
|
||||
@@ -157,7 +157,11 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
interval: IInterval;
|
||||
}
|
||||
>(({ data, context }) => {
|
||||
const { date } = data[0]!;
|
||||
if (!data[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { date } = data[0];
|
||||
const formatDate = useFormatDateInterval(context.interval);
|
||||
const number = useNumber();
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { mergeDeepRight } from 'ramda';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { memo, useEffect, useRef } from 'react';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
|
||||
import { shallowEqual } from 'react-redux';
|
||||
import { ReportAreaChart } from './area';
|
||||
import { ReportBarChart } from './bar';
|
||||
import type { ReportChartProps } from './context';
|
||||
@@ -17,7 +18,7 @@ import { ReportMetricChart } from './metric';
|
||||
import { ReportPieChart } from './pie';
|
||||
import { ReportRetentionChart } from './retention';
|
||||
|
||||
export function ReportChart(props: ReportChartProps) {
|
||||
export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const once = useRef(false);
|
||||
const { inViewport } = useInViewport(ref, undefined, {
|
||||
@@ -30,7 +31,7 @@ export function ReportChart(props: ReportChartProps) {
|
||||
}
|
||||
}, [inViewport]);
|
||||
|
||||
const loaded = once.current || inViewport;
|
||||
const loaded = lazy ? once.current || inViewport : true;
|
||||
|
||||
const renderReportChart = () => {
|
||||
switch (props.report.chartType) {
|
||||
@@ -69,4 +70,4 @@ export function ReportChart(props: ReportChartProps) {
|
||||
</ReportChartProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { chartTypes } from '@openpanel/constants';
|
||||
import { objectToZodEnums } from '@openpanel/validation';
|
||||
import { type IChartType, objectToZodEnums } from '@openpanel/validation';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -32,10 +32,14 @@ import { changeChartType } from './reportSlice';
|
||||
|
||||
interface ReportChartTypeProps {
|
||||
className?: string;
|
||||
value: IChartType;
|
||||
onChange: (type: IChartType) => void;
|
||||
}
|
||||
export function ReportChartType({ className }: ReportChartTypeProps) {
|
||||
const dispatch = useDispatch();
|
||||
const type = useSelector((state) => state.report.chartType);
|
||||
export function ReportChartType({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
}: ReportChartTypeProps) {
|
||||
const items = objectToZodEnums(chartTypes).map((key) => ({
|
||||
label: chartTypes[key],
|
||||
value: key,
|
||||
@@ -61,10 +65,10 @@ export function ReportChartType({ className }: ReportChartTypeProps) {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={Icons[type]}
|
||||
icon={Icons[value]}
|
||||
className={cn('justify-start', className)}
|
||||
>
|
||||
{items.find((item) => item.value === type)?.label}
|
||||
{items.find((item) => item.value === value)?.label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
@@ -77,7 +81,7 @@ export function ReportChartType({ className }: ReportChartTypeProps) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.value}
|
||||
onClick={() => dispatch(changeChartType(item.value))}
|
||||
onClick={() => onChange(item.value)}
|
||||
className="group"
|
||||
>
|
||||
{item.label}
|
||||
|
||||
@@ -6,17 +6,36 @@ import {
|
||||
isMinuteIntervalEnabledByRange,
|
||||
} from '@openpanel/constants';
|
||||
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { cn } from '@/utils/cn';
|
||||
import type { IChartRange, IChartType, IInterval } from '@openpanel/validation';
|
||||
import { Button } from '../ui/button';
|
||||
import { CommandShortcut } from '../ui/command';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
import { changeInterval } from './reportSlice';
|
||||
|
||||
interface ReportIntervalProps {
|
||||
className?: string;
|
||||
interval: IInterval;
|
||||
onChange: (range: IInterval) => void;
|
||||
chartType: IChartType;
|
||||
range: IChartRange;
|
||||
}
|
||||
export function ReportInterval({ className }: ReportIntervalProps) {
|
||||
const dispatch = useDispatch();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const range = useSelector((state) => state.report.range);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
export function ReportInterval({
|
||||
className,
|
||||
interval,
|
||||
onChange,
|
||||
chartType,
|
||||
range,
|
||||
}: ReportIntervalProps) {
|
||||
if (
|
||||
chartType !== 'linear' &&
|
||||
chartType !== 'histogram' &&
|
||||
@@ -28,37 +47,66 @@ export function ReportInterval({ className }: ReportIntervalProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
value: 'minute',
|
||||
label: 'Minute',
|
||||
disabled: !isMinuteIntervalEnabledByRange(range),
|
||||
},
|
||||
{
|
||||
value: 'hour',
|
||||
label: 'Hour',
|
||||
disabled: !isHourIntervalEnabledByRange(range),
|
||||
},
|
||||
{
|
||||
value: 'day',
|
||||
label: 'Day',
|
||||
},
|
||||
{
|
||||
value: 'month',
|
||||
label: 'Month',
|
||||
disabled: range === 'today' || range === 'lastHour' || range === '30min',
|
||||
},
|
||||
];
|
||||
|
||||
const selectedItem = items.find((item) => item.value === interval);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
icon={ClockIcon}
|
||||
className={className}
|
||||
placeholder="Interval"
|
||||
onChange={(value) => {
|
||||
dispatch(changeInterval(value));
|
||||
}}
|
||||
value={interval}
|
||||
items={[
|
||||
{
|
||||
value: 'minute',
|
||||
label: 'Minute',
|
||||
disabled: !isMinuteIntervalEnabledByRange(range),
|
||||
},
|
||||
{
|
||||
value: 'hour',
|
||||
label: 'Hour',
|
||||
disabled: !isHourIntervalEnabledByRange(range),
|
||||
},
|
||||
{
|
||||
value: 'day',
|
||||
label: 'Day',
|
||||
},
|
||||
{
|
||||
value: 'month',
|
||||
label: 'Month',
|
||||
disabled:
|
||||
range === 'today' || range === 'lastHour' || range === '30min',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
icon={ClockIcon}
|
||||
className={cn('justify-start', className)}
|
||||
>
|
||||
{items.find((item) => item.value === interval)?.label || 'Interval'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuLabel className="row items-center justify-between">
|
||||
Select interval
|
||||
{!!selectedItem && (
|
||||
<CommandShortcut>{selectedItem?.label}</CommandShortcut>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.value}
|
||||
onClick={() => onChange(item.value as IInterval)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.label}
|
||||
{item.value === interval && (
|
||||
<DropdownMenuShortcut>
|
||||
<ClockIcon className="size-4" />
|
||||
</DropdownMenuShortcut>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,22 +5,26 @@ import { cn } from '@/utils/cn';
|
||||
import { CopyIcon } from 'lucide-react';
|
||||
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash';
|
||||
import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json';
|
||||
import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript';
|
||||
import docco from 'react-syntax-highlighter/dist/cjs/styles/hljs/vs2015';
|
||||
|
||||
SyntaxHighlighter.registerLanguage('typescript', ts);
|
||||
SyntaxHighlighter.registerLanguage('json', json);
|
||||
SyntaxHighlighter.registerLanguage('bash', bash);
|
||||
|
||||
interface SyntaxProps {
|
||||
code: string;
|
||||
className?: string;
|
||||
language?: 'typescript' | 'bash';
|
||||
language?: 'typescript' | 'bash' | 'json';
|
||||
wrapLines?: boolean;
|
||||
}
|
||||
|
||||
export default function Syntax({
|
||||
code,
|
||||
className,
|
||||
language = 'typescript',
|
||||
wrapLines = false,
|
||||
}: SyntaxProps) {
|
||||
return (
|
||||
<div className={cn('group relative rounded-lg', className)}>
|
||||
@@ -35,7 +39,7 @@ export default function Syntax({
|
||||
<CopyIcon size={12} />
|
||||
</button>
|
||||
<SyntaxHighlighter
|
||||
// wrapLongLines
|
||||
wrapLongLines={wrapLines}
|
||||
style={docco}
|
||||
language={language}
|
||||
customStyle={{
|
||||
|
||||
@@ -6,17 +6,21 @@ import * as React from 'react';
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
|
||||
orientation?: 'vertical' | 'horizontal';
|
||||
}
|
||||
>(({ className, children, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
className="h-full w-full rounded-[inherit]"
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollBar orientation={orientation} />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
|
||||
82
apps/dashboard/src/hooks/use-scroll-anchor.ts
Normal file
82
apps/dashboard/src/hooks/use-scroll-anchor.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export const useScrollAnchor = () => {
|
||||
const messagesRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const visibilityRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (messagesRef.current) {
|
||||
messagesRef.current.scrollIntoView({
|
||||
block: 'end',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesRef.current) {
|
||||
if (isAtBottom && !isVisible) {
|
||||
messagesRef.current.scrollIntoView({
|
||||
block: 'end',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isAtBottom, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
const { current } = scrollRef;
|
||||
|
||||
if (current) {
|
||||
const handleScroll = (event: Event) => {
|
||||
const target = event.target as HTMLDivElement;
|
||||
const offset = 20;
|
||||
const isAtBottom =
|
||||
target.scrollTop + target.clientHeight >=
|
||||
target.scrollHeight - offset;
|
||||
|
||||
setIsAtBottom(isAtBottom);
|
||||
};
|
||||
|
||||
current.addEventListener('scroll', handleScroll, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
current.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibilityRef.current) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(visibilityRef.current);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
messagesRef,
|
||||
scrollRef,
|
||||
visibilityRef,
|
||||
scrollToBottom,
|
||||
isAtBottom,
|
||||
isVisible,
|
||||
};
|
||||
};
|
||||
@@ -18,7 +18,7 @@ import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
|
||||
type SaveReportProps = {
|
||||
report: IChartProps;
|
||||
reportId?: string;
|
||||
disableRedirect?: boolean;
|
||||
};
|
||||
|
||||
const validator = z.object({
|
||||
@@ -28,7 +28,10 @@ const validator = z.object({
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
export default function SaveReport({ report }: SaveReportProps) {
|
||||
export default function SaveReport({
|
||||
report,
|
||||
disableRedirect,
|
||||
}: SaveReportProps) {
|
||||
const router = useRouter();
|
||||
const { organizationId, projectId } = useAppParams();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -37,15 +40,27 @@ export default function SaveReport({ report }: SaveReportProps) {
|
||||
const save = api.report.create.useMutation({
|
||||
onError: handleError,
|
||||
onSuccess(res) {
|
||||
toast('Success', {
|
||||
description: 'Report saved.',
|
||||
const goToReport = () => {
|
||||
router.push(
|
||||
`/${organizationId}/${projectId}/reports/${
|
||||
res.id
|
||||
}?${searchParams?.toString()}`,
|
||||
);
|
||||
};
|
||||
|
||||
toast('Report created', {
|
||||
description: <div>Hello world</div>,
|
||||
action: {
|
||||
label: 'View report',
|
||||
onClick: () => goToReport(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!disableRedirect) {
|
||||
goToReport();
|
||||
}
|
||||
|
||||
popModal();
|
||||
router.push(
|
||||
`/${organizationId}/${projectId}/reports/${
|
||||
res.id
|
||||
}?${searchParams?.toString()}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -168,4 +168,4 @@
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user