From abe155e6cac0262b6a670743aaa7c1fa51e59dfb Mon Sep 17 00:00:00 2001 From: David Humphrey Date: Sat, 4 Jan 2025 16:32:02 -0500 Subject: [PATCH] Better stats, UI, UX --- src/components/Header.tsx | 23 ++ .../Message/AppMessage/Analytics.tsx | 330 ++++++++++++++++++ src/components/Message/AppMessage/index.tsx | 9 +- src/components/Message/ComponentMessage.tsx | 65 ++++ src/lib/ChatCraftMessage.ts | 8 + src/lib/analytics.ts | 134 ++++++- src/lib/commands/AnalyticsCommand.ts | 10 +- 7 files changed, 553 insertions(+), 26 deletions(-) create mode 100644 src/components/Message/AppMessage/Analytics.tsx create mode 100644 src/components/Message/ComponentMessage.tsx diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 075a5ebe..6974d2d4 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -27,6 +27,9 @@ import { Form } from "react-router-dom"; import PreferencesModal from "./Preferences/PreferencesModal"; import { useUser } from "../hooks/use-user"; import useMobileBreakpoint from "../hooks/use-mobile-breakpoint"; +import { ChatCraftChat } from "../lib/ChatCraftChat"; +import { useAlert } from "../hooks/use-alert"; +import { ChatCraftAppMessage } from "../lib/ChatCraftMessage"; type HeaderProps = { chatId?: string; @@ -37,6 +40,7 @@ type HeaderProps = { function Header({ chatId, inputPromptRef, searchText, onToggleSidebar }: HeaderProps) { const { toggleColorMode } = useColorMode(); + const { error } = useAlert(); const { isOpen: isPrefModalOpen, onOpen: onPrefModalOpen, @@ -55,6 +59,22 @@ function Header({ chatId, inputPromptRef, searchText, onToggleSidebar }: HeaderP [chatId, user, login, logout] ); + const handleShowAnalytics = useCallback( + async (chatId: string) => { + const chat = await ChatCraftChat.find(chatId); + if (!chat) { + console.error("Couldn't find chat with given chatId"); + return error({ + title: "Error Displaying Analytics", + message: "Unable to add Analytics message to chat: no chat found", + }); + } + + chat.addMessage(new ChatCraftAppMessage({ text: "app:analytics" })); + }, + [error] + ); + const isMobile = useMobileBreakpoint(); return ( @@ -153,6 +173,9 @@ function Header({ chatId, inputPromptRef, searchText, onToggleSidebar }: HeaderP /> Settings... + {!!chatId && ( + handleShowAnalytics(chatId)}>Analytics + )} {user ? ( { diff --git a/src/components/Message/AppMessage/Analytics.tsx b/src/components/Message/AppMessage/Analytics.tsx new file mode 100644 index 00000000..818d3675 --- /dev/null +++ b/src/components/Message/AppMessage/Analytics.tsx @@ -0,0 +1,330 @@ +import { memo, useEffect, useState } from "react"; +import { + Avatar, + Box, + ButtonGroup, + Button, + Heading, + SimpleGrid, + Stat, + StatLabel, + StatNumber, + StatHelpText, + Text, + VStack, +} from "@chakra-ui/react"; +import { + BarChart, + Bar, + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + Legend, + ResponsiveContainer, + LabelProps, +} from "recharts"; +import ComponentMessage from "../ComponentMessage"; +import { ProcessedAnalytics, processAnalytics } from "../../../lib/analytics"; + +type Period = "1H" | "1D" | "1W" | "1M" | "1Y" | "ALL"; + +function getPeriodLabel(period: Period, context: "button" | "peak" | "normal" = "normal"): string { + const labels: Record = { + "1H": { normal: "hour", peak: "a single hour" }, + "1D": { normal: "day", peak: "a single day" }, + "1W": { normal: "week", peak: "a single week" }, + "1M": { normal: "month", peak: "a single month" }, + "1Y": { normal: "year", peak: "a single year" }, + ALL: { normal: "all time", peak: "any period" }, + }; + + const label = context === "peak" ? labels[period].peak : labels[period].normal; + + return context === "button" ? label.charAt(0).toUpperCase() + label.slice(1) : label; +} + +const COLORS = { + chats: "#8884d8", + messages: "#82ca9d", + characters: "#F2994A", +}; + +function formatLargeNumber(num: number): string { + if (num >= 1_000_000) { + return `${(num / 1_000_000).toFixed(1)}M`; + } + if (num >= 1_000) { + return `${(num / 1_000).toFixed(1)}K`; + } + return num.toString(); +} + +function Analytics() { + const [period, setPeriod] = useState("1M"); + const [analytics, setAnalytics] = useState(null); + + useEffect(() => { + const endDate = new Date(); + const startDate = new Date(); + + switch (period) { + case "1H": + startDate.setHours(endDate.getHours() - 1); + break; + case "1D": + startDate.setHours(0, 0, 0, 0); + break; + case "1W": + startDate.setDate(endDate.getDate() - 7); + startDate.setHours(0, 0, 0, 0); + break; + case "1M": + startDate.setMonth(endDate.getMonth() - 1); + startDate.setHours(0, 0, 0, 0); + break; + case "1Y": + startDate.setFullYear(endDate.getFullYear() - 1); + startDate.setHours(0, 0, 0, 0); + break; + case "ALL": + startDate.setFullYear(2019); + break; + } + + processAnalytics(startDate, endDate).then(setAnalytics); + }, [period]); + + const avatar = ( + + ); + + if (!analytics) { + return null; + } + + return ( + + + + + + + + + + + + + + + + Total Chats + + {analytics.summary.totals.chats.toLocaleString()} + + + Peak Activity + + {analytics.summary.max.chats} chats in {getPeriodLabel(period, "peak")} + + + + + + Total Messages + + {analytics.summary.totals.messages.toLocaleString()} + + + Average Activity + {analytics.summary.averages.messagesPerChat} messages per chat + + + + + Total Characters + + {formatLargeNumber(analytics.summary.totals.characters)} + + + Average Length + + {formatLargeNumber(Number(analytics.summary.averages.charactersPerMessage))} chars + per message + + + + + + + + Chat Overview + + + + + + + { + const date = new Date(label); + switch (period) { + case "1H": + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + case "1D": + case "1W": + case "1M": + return date.toLocaleDateString([], { + month: "short", + day: "numeric", + }); + default: + return date.toLocaleDateString([], { + month: "short", + year: "numeric", + }); + } + }} + formatter={(value, name: string) => { + if (name === "characters") { + return [`${value}K characters`, "Characters"]; + } + return [value, name.charAt(0).toUpperCase() + name.slice(1)]; + }} + /> + + + + + + + + + + + Model Usage + + + + + + [`${value} messages`, "Usage"]} /> + { + const { x, y, width, value } = props; + const total = analytics.modelUsage.reduce((sum, model) => sum + model.value, 0); + const percentage = ((Number(value) / total) * 100).toFixed(1); + + return ( + + {`${percentage}%`} + + ); + }} + /> + + + + + + ); +} + +export default memo(Analytics); diff --git a/src/components/Message/AppMessage/index.tsx b/src/components/Message/AppMessage/index.tsx index f4ea0200..e792f9ff 100644 --- a/src/components/Message/AppMessage/index.tsx +++ b/src/components/Message/AppMessage/index.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { lazy, memo } from "react"; import { Avatar } from "@chakra-ui/react"; import MessageBase, { type MessageBaseProps } from "../MessageBase"; @@ -7,6 +7,9 @@ import Instructions from "./Instructions"; import Help from "./Help"; import { CommandsHelpCommand } from "../../../lib/commands/CommandsHelpCommand"; +// Lazy-load Analytics, since it also pulls in Recharts +const Analytics = lazy(() => import("./Analytics")); + type AppMessageProps = Omit; function AppMessage(props: AppMessageProps) { @@ -56,6 +59,10 @@ function AppMessage(props: AppMessageProps) { ); } + if (ChatCraftAppMessage.isAnalytics(message)) { + return ; + } + // Otherwise, use a basic message type and show the text return ( + + + + + {avatar && {avatar}} + + + + {heading && ( + + {heading} + + )} + + {headingMenu} + + + + + + + + + + {children} + + + + + {footer && {footer}} + + + ); +} + +export default ComponentMessage; diff --git a/src/lib/ChatCraftMessage.ts b/src/lib/ChatCraftMessage.ts index c7fdb425..18bb1646 100644 --- a/src/lib/ChatCraftMessage.ts +++ b/src/lib/ChatCraftMessage.ts @@ -526,6 +526,14 @@ export class ChatCraftAppMessage extends ChatCraftMessage { static isCommandsHelp(message: ChatCraftMessage) { return message instanceof ChatCraftAppMessage && message.text.startsWith("app:commands"); } + + // Analytics Message + static analytics() { + return new ChatCraftAppMessage({ text: "app:analytics" }); + } + static isAnalytics(message: ChatCraftMessage) { + return message instanceof ChatCraftAppMessage && message.text === "app:analytics"; + } } /** diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 86b60874..e6c1d052 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -77,6 +77,40 @@ export interface ChatAnalytics { }; } +export interface AnalyticsSummary { + totals: { + chats: number; + messages: number; + characters: number; + }; + max: { + chats: number; + messages: number; + characters: number; + }; + averages: { + messagesPerChat: number; + charactersPerMessage: number; + }; +} + +export interface FormattedModelUsage { + name: string; + value: number; + percentage: number; +} + +export interface ProcessedAnalytics { + timeSeriesData: Array<{ + period: string; + chats: number; + messages: number; + characters: number; + }>; + modelUsage: FormattedModelUsage[]; + summary: AnalyticsSummary; +} + export async function generateAnalytics(startDate?: Date, endDate?: Date): Promise { const messages = await db.messages .where("date") @@ -296,23 +330,91 @@ export async function generateAnalytics(startDate?: Date, endDate?: Date): Promi return results; } -export function visualizeAnalytics(analytics: ChatAnalytics) { - // Model Usage Chart - const modelUsageData = Object.entries(analytics.modelMetrics.usage).map(([model, stats]) => ({ - model, - messages: stats.messageCount, - characters: stats.characterCount, - })); - - // Time Distribution Chart - const timeData = Object.entries(analytics.timeMetrics.byPeriod).map(([period, stats]) => ({ - period, - messages: stats.messageCount, - conversations: stats.conversationCount, - })); +export async function processAnalytics( + startDate?: Date, + endDate?: Date +): Promise { + const rawAnalytics = await generateAnalytics(startDate, endDate); + + const summary: AnalyticsSummary = { + totals: { + chats: Object.values(rawAnalytics.timeMetrics.byPeriod).reduce( + (sum, stats) => sum + stats.conversationCount, + 0 + ), + messages: Object.values(rawAnalytics.timeMetrics.byPeriod).reduce( + (sum, stats) => sum + stats.messageCount, + 0 + ), + characters: Object.values(rawAnalytics.timeMetrics.byPeriod).reduce( + (sum, stats) => sum + stats.characterCount, + 0 + ), + }, + max: { + chats: Math.max( + ...Object.values(rawAnalytics.timeMetrics.byPeriod).map((stats) => stats.conversationCount) + ), + messages: Math.max( + ...Object.values(rawAnalytics.timeMetrics.byPeriod).map((stats) => stats.messageCount) + ), + characters: Math.max( + ...Object.values(rawAnalytics.timeMetrics.byPeriod).map((stats) => stats.characterCount) + ), + }, + averages: { + messagesPerChat: Number( + ( + Object.values(rawAnalytics.timeMetrics.byPeriod).reduce( + (sum, stats) => sum + stats.messageCount, + 0 + ) / + Object.values(rawAnalytics.timeMetrics.byPeriod).reduce( + (sum, stats) => sum + stats.conversationCount, + 0 + ) + ).toFixed(1) + ), + charactersPerMessage: Number( + ( + Object.values(rawAnalytics.timeMetrics.byPeriod).reduce( + (sum, stats) => sum + stats.characterCount, + 0 + ) / + Object.values(rawAnalytics.timeMetrics.byPeriod).reduce( + (sum, stats) => sum + stats.messageCount, + 0 + ) + ).toFixed(0) + ), + }, + }; + + const timeSeriesData = Object.entries(rawAnalytics.timeMetrics.byPeriod) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([period, stats]) => ({ + period, + chats: stats.conversationCount, + messages: stats.messageCount, + characters: Math.round(stats.characterCount / 1000), + })); + + const totalMessages = Object.values(rawAnalytics.modelMetrics.usage).reduce( + (sum, stats) => sum + stats.messageCount, + 0 + ); + + const modelUsage = Object.entries(rawAnalytics.modelMetrics.usage) + .map(([model, stats]) => ({ + name: model, + value: stats.messageCount, + percentage: Number(((stats.messageCount / totalMessages) * 100).toFixed(1)), + })) + .sort((a, b) => b.value - a.value); return { - modelUsageData, - timeData, + timeSeriesData, + modelUsage, + summary, }; } diff --git a/src/lib/commands/AnalyticsCommand.ts b/src/lib/commands/AnalyticsCommand.ts index 5e01cac2..1364b9e5 100644 --- a/src/lib/commands/AnalyticsCommand.ts +++ b/src/lib/commands/AnalyticsCommand.ts @@ -1,6 +1,5 @@ import { ChatCraftCommand } from "../ChatCraftCommand"; import { ChatCraftChat } from "../ChatCraftChat"; -import { generateAnalytics, visualizeAnalytics } from "../analytics"; import { ChatCraftAppMessage } from "../ChatCraftMessage"; export class AnalyticsCommand extends ChatCraftCommand { @@ -9,13 +8,6 @@ export class AnalyticsCommand extends ChatCraftCommand { } async execute(chat: ChatCraftChat) { - const analytics = await generateAnalytics(); - chat.addMessage( - new ChatCraftAppMessage({ text: "```json\n" + JSON.stringify(analytics, null, 2) + "\n```" }) - ); - const visualized = visualizeAnalytics(analytics); - chat.addMessage( - new ChatCraftAppMessage({ text: "```json\n" + JSON.stringify(visualized, null) + "\n```" }) - ); + chat.addMessage(new ChatCraftAppMessage({ text: "app:analytics" })); } }