diff --git a/package.json b/package.json index c6d53799..3efe92ef 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "react-syntax-highlighter": "^15.6.1", "react-textarea-autosize": "^8.5.6", "react-use": "^17.6.0", + "recharts": "^2.15.0", "rehype-external-links": "^3.0.0", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 856dcab8..68b5f895 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: react-use: specifier: ^17.6.0 version: 17.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts: + specifier: ^2.15.0 + version: 2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rehype-external-links: specifier: ^3.0.0 version: 3.0.0 @@ -2479,6 +2482,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + codemirror@6.0.1: resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} @@ -2818,6 +2825,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-named-character-reference@1.0.2: resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} @@ -2892,6 +2902,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + domain-browser@4.22.0: resolution: {integrity: sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==} engines: {node: '>=10'} @@ -3147,6 +3160,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -3185,6 +3201,10 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-equals@5.0.1: + resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -4607,6 +4627,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-markdown@9.0.1: resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==} peerDependencies: @@ -4650,6 +4673,12 @@ packages: peerDependencies: react: '>=16.8' + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -4671,6 +4700,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react-transition-state@2.2.0: resolution: {integrity: sha512-D3EyLku1Sdxrxq26Fo4Jh0q1BLEFQfDOxKKiSuyqWH84+hM6y0Guc0hcW2IXMXY5l5gQCgkOQ9y90xx6mNoj5w==} peerDependencies: @@ -4704,6 +4739,16 @@ packages: resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==} engines: {node: '>= 14.16.0'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.0: + resolution: {integrity: sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + reflect.getprototypeof@1.0.9: resolution: {integrity: sha512-r0Ay04Snci87djAsI4U+WNRcSw5S4pOH7qFjd/veA5gC7TbqESR3tcj28ia95L/fYUDw11JKP7uqUKUAfVvV5Q==} engines: {node: '>= 0.4'} @@ -5177,6 +5222,9 @@ packages: resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==} engines: {node: '>=0.6.0'} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -5460,6 +5508,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite-node@2.1.8: resolution: {integrity: sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -8232,6 +8283,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clsx@2.1.1: {} + codemirror@6.0.1: dependencies: '@codemirror/autocomplete': 6.18.4 @@ -8614,6 +8667,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decode-named-character-reference@1.0.2: dependencies: character-entities: 2.0.2 @@ -8683,6 +8738,11 @@ snapshots: dependencies: esutils: 2.0.3 + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.26.0 + csstype: 3.1.3 + domain-browser@4.22.0: {} dompurify@3.2.3: @@ -9108,6 +9168,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + eventemitter3@5.0.1: {} events@3.3.0: {} @@ -9153,6 +9215,8 @@ snapshots: fast-diff@1.3.0: {} + fast-equals@5.0.1: {} + fast-glob@3.3.2: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10892,6 +10956,8 @@ snapshots: react-is@16.13.1: {} + react-is@18.3.1: {} + react-markdown@9.0.1(@types/react@18.3.18)(react@18.3.1): dependencies: '@types/hast': 3.0.4 @@ -10942,6 +11008,14 @@ snapshots: '@remix-run/router': 1.21.0 react: 18.3.1 + react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + fast-equals: 5.0.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-style-singleton@2.2.3(@types/react@18.3.18)(react@18.3.1): dependencies: get-nonce: 1.0.1 @@ -10969,6 +11043,15 @@ snapshots: transitivePeerDependencies: - '@types/react' + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.26.0 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-state@2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 @@ -11020,6 +11103,23 @@ snapshots: readdirp@4.0.2: {} + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.17.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + reflect.getprototypeof@1.0.9: dependencies: call-bind: 1.0.8 @@ -11602,6 +11702,8 @@ snapshots: dependencies: setimmediate: 1.0.5 + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -11892,6 +11994,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.8 + '@types/d3-shape': 3.1.6 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-node@2.1.8(@types/node@22.10.3)(terser@5.37.0): dependencies: cac: 6.7.14 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..bffb5b7a --- /dev/null +++ b/src/components/Message/AppMessage/Analytics.tsx @@ -0,0 +1,334 @@ +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"; +import db from "../../../lib/db"; + +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; + } + + // TODO: would be good to figure out how to only do this export once + db.exportToDuckDB() + .then(() => processAnalytics(startDate, endDate, period)) + .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 new file mode 100644 index 00000000..da00f3a0 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,192 @@ +import { query } from "./duckdb"; + +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 processAnalytics( + startDate: Date, + endDate: Date, + period: "1H" | "1D" | "1W" | "1M" | "1Y" | "ALL" +): Promise { + // Convert dates to ISO strings for SQL + const start = startDate?.toISOString() || "2019-01-01"; + const end = endDate?.toISOString() || new Date().toISOString(); + + const summaryQuery = ` + WITH message_stats AS ( + SELECT + CAST(COUNT(DISTINCT chatId) AS DOUBLE) as total_chats, + CAST(COUNT(*) AS DOUBLE) as total_messages, + CAST(SUM(LENGTH(text)) AS DOUBLE) as total_characters + FROM messages + WHERE date BETWEEN '${start}' AND '${end}' + ), + max_stats AS ( + SELECT + CAST(MAX(chats) AS DOUBLE) as max_chats, + CAST(MAX(messages) AS DOUBLE) as max_messages, + CAST(MAX(chars) AS DOUBLE) as max_characters + FROM ( + SELECT + DATE_TRUNC('day', CAST(date AS TIMESTAMP)) as day, + COUNT(DISTINCT chatId) as chats, + COUNT(*) as messages, + SUM(LENGTH(text)) as chars + FROM messages + WHERE date BETWEEN '${start}' AND '${end}' + GROUP BY 1 + ) + ) + SELECT + m.*, + x.*, + CAST(ROUND(CAST(m.total_messages AS DOUBLE) / + NULLIF(m.total_chats, 0), 1) AS DOUBLE) as avg_messages_per_chat, + CAST(ROUND(CAST(m.total_characters AS DOUBLE) / + NULLIF(m.total_messages, 0), 0) AS DOUBLE) as avg_chars_per_message + FROM message_stats m + CROSS JOIN max_stats x + `; + + const timeSeriesQuery = ` + WITH RECURSIVE + time_series AS ( + SELECT + CASE '${period}' + WHEN '1H' THEN + DATE_TRUNC('minute', CAST('${start}' AS TIMESTAMP)) + WHEN '1D' THEN + DATE_TRUNC('hour', CAST('${start}' AS TIMESTAMP)) + WHEN '1W' THEN + DATE_TRUNC('day', CAST('${start}' AS TIMESTAMP)) + WHEN '1M' THEN + DATE_TRUNC('day', CAST('${start}' AS TIMESTAMP)) + ELSE + DATE_TRUNC('month', CAST('${start}' AS TIMESTAMP)) + END as period + UNION ALL + SELECT + CASE '${period}' + WHEN '1H' THEN period + INTERVAL 1 MINUTE + WHEN '1D' THEN period + INTERVAL 1 HOUR + WHEN '1W' THEN period + INTERVAL 1 DAY + WHEN '1M' THEN period + INTERVAL 1 DAY + ELSE period + INTERVAL 1 MONTH + END + FROM time_series + WHERE period < CAST('${end}' AS TIMESTAMP) + ), + message_stats AS ( + SELECT + CASE '${period}' + WHEN '1H' THEN DATE_TRUNC('minute', CAST(date AS TIMESTAMP)) + WHEN '1D' THEN DATE_TRUNC('hour', CAST(date AS TIMESTAMP)) + WHEN '1W' THEN DATE_TRUNC('day', CAST(date AS TIMESTAMP)) + WHEN '1M' THEN DATE_TRUNC('day', CAST(date AS TIMESTAMP)) + ELSE DATE_TRUNC('month', CAST(date AS TIMESTAMP)) + END as period, + CAST(COUNT(DISTINCT chatId) AS DOUBLE) AS chats, + CAST(COUNT(*) AS DOUBLE) AS messages, + CAST(ROUND(SUM(LENGTH(text)) / 1000.0) AS DOUBLE) AS characters + FROM messages + WHERE date BETWEEN '${start}' AND '${end}' + GROUP BY 1 + ) + SELECT + ts.period, + COALESCE(ms.chats, 0) as chats, + COALESCE(ms.messages, 0) as messages, + COALESCE(ms.characters, 0) as characters + FROM time_series ts + LEFT JOIN message_stats ms ON ts.period = ms.period + ORDER BY ts.period + `; + + const modelUsageQuery = ` + WITH model_counts AS ( + SELECT + model, + CAST(COUNT(*) AS DOUBLE) as message_count + FROM messages + WHERE date BETWEEN '${start}' AND '${end}' + AND model IS NOT NULL + GROUP BY model + ), + total_messages AS ( + SELECT CAST(SUM(message_count) AS DOUBLE) as total + FROM model_counts + ) + SELECT + model as name, + CAST(message_count AS DOUBLE) as value, + CAST(ROUND(CAST(message_count AS DOUBLE) * 100 / total, 1) AS DOUBLE) as percentage + FROM model_counts + CROSS JOIN total_messages + ORDER BY message_count DESC + `; + + const [summaryResult, timeSeriesResult, modelUsageResult] = await Promise.all([ + query(summaryQuery), + query(timeSeriesQuery), + query(modelUsageQuery), + ]); + + const summary = summaryResult.toArray()[0]; + + return { + summary: { + totals: { + chats: summary.total_chats, + messages: summary.total_messages, + characters: summary.total_characters, + }, + max: { + chats: summary.max_chats, + messages: summary.max_messages, + characters: summary.max_characters, + }, + averages: { + messagesPerChat: summary.avg_messages_per_chat, + charactersPerMessage: summary.avg_chars_per_message, + }, + }, + timeSeriesData: timeSeriesResult.toArray().map((row) => ({ + period: row.period, + chats: row.chats, + messages: row.messages, + characters: row.characters, + })), + modelUsage: modelUsageResult.toArray(), + }; +} diff --git a/src/lib/commands/AnalyticsCommand.ts b/src/lib/commands/AnalyticsCommand.ts new file mode 100644 index 00000000..1364b9e5 --- /dev/null +++ b/src/lib/commands/AnalyticsCommand.ts @@ -0,0 +1,13 @@ +import { ChatCraftCommand } from "../ChatCraftCommand"; +import { ChatCraftChat } from "../ChatCraftChat"; +import { ChatCraftAppMessage } from "../ChatCraftMessage"; + +export class AnalyticsCommand extends ChatCraftCommand { + constructor() { + super("analytics", "/analytics", "Generate chat analytics."); + } + + async execute(chat: ChatCraftChat) { + chat.addMessage(new ChatCraftAppMessage({ text: "app:analytics" })); + } +} diff --git a/src/lib/commands/index.ts b/src/lib/commands/index.ts index ff531da6..d68e0ffc 100644 --- a/src/lib/commands/index.ts +++ b/src/lib/commands/index.ts @@ -10,6 +10,7 @@ import { CommandsHelpCommand } from "./CommandsHelpCommand"; import { ImageCommand } from "./ImageCommand"; import { StatsCommand } from "./StatsCommand"; import { DuckCommand } from "./DuckCommand"; +import { AnalyticsCommand } from "./AnalyticsCommand"; // Register all our commands ChatCraftCommandRegistry.registerCommand(new NewCommand()); @@ -21,3 +22,4 @@ ChatCraftCommandRegistry.registerCommand(new ImportCommand()); ChatCraftCommandRegistry.registerCommand(new ImageCommand()); ChatCraftCommandRegistry.registerCommand(new StatsCommand()); ChatCraftCommandRegistry.registerCommand(new DuckCommand()); +ChatCraftCommandRegistry.registerCommand(new AnalyticsCommand());