diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-bar.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-bar.tsx new file mode 100644 index 000000000..5c628c8fe --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-bar.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { SimpleTooltip } from "@/components/ui/simple-tooltip"; +import { cn } from "@/lib/utils"; +import { + EyeIcon, + PaperPlaneTiltIcon, + SparkleIcon, + SpinnerGapIcon, + XIcon, +} from "@phosphor-icons/react"; +import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; +import { useCallback, useState, type KeyboardEvent } from "react"; +import type { AiQueryChat } from "./use-ai-query-chat"; + +type AiQueryBarProps = { + chat: AiQueryChat, + /** Whether the AI has committed a query (drives the purple accent). */ + isActive: boolean, + /** Invoked when the user clicks the eye button. */ + onOpenDialog: () => void, + /** Invoked when the user clicks the reset (clear) button. */ + onReset: () => void, +}; + +/** + * AI-powered search bar for the analytics tables page. Drops into the + * DataGridToolbar in place of the built-in quick-search input. Typing + * a message and pressing Enter sends a prompt to the shared AI chat; + * the AI responds by calling the `queryAnalytics` tool, and the + * extracted query drives the grid (via the parent component). + */ +export function AiQueryBar({ + chat, + isActive, + onOpenDialog, + onReset, +}: AiQueryBarProps) { + const [input, setInput] = useState(""); + + const handleSubmit = useCallback(() => { + const text = input.trim(); + if (!text || chat.isResponding) return; + setInput(""); + runAsynchronously(chat.sendMessage({ text })); + }, [input, chat]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit], + ); + + return ( +
+
+ + setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={ + isActive ? "Refine with AI…" : "Ask about your analytics…" + } + className={cn( + "min-w-0 flex-1 bg-transparent text-xs outline-none", + "placeholder:text-muted-foreground/40", + )} + disabled={chat.isResponding} + /> + {chat.isResponding && ( + + )} + {!chat.isResponding && input.trim().length > 0 && ( + + )} + + + +
+ + {isActive && ( + + + + )} +
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx new file mode 100644 index 000000000..6273cc4ee --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx @@ -0,0 +1,483 @@ +"use client"; + +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { Thread } from "@/components/assistant-ui/thread"; +import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import type { CmdKPreviewProps } from "@/components/cmdk-commands"; +import { Button } from "@/components/ui"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SimpleTooltip } from "@/components/ui/simple-tooltip"; +import { Textarea } from "@/components/ui/textarea"; +import { CreateDashboardPreview } from "@/components/commands/create-dashboard/create-dashboard-preview"; +import { useUpdateConfig } from "@/components/config-update"; +import { AssistantRuntimeProvider, type ToolCallContentPartProps } from "@assistant-ui/react"; +import { + ArrowClockwiseIcon, + CheckIcon, + CopyIcon, + FloppyDiskIcon, + LayoutIcon, + SparkleIcon, + TrashIcon, + XIcon, +} from "@phosphor-icons/react"; +import { generateSecureRandomString } from "@hexclave/shared/dist/utils/crypto"; +import { + runAsynchronously, + runAsynchronouslyWithAlert, +} from "@hexclave/shared/dist/utils/promises"; +import { + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { useAdminApp } from "../../use-admin-app"; +import type { AiQueryChat } from "./use-ai-query-chat"; + +function AnalyticsQueryToolCall( + props: ToolCallContentPartProps & { + currentQuery: string | null, + onApplyQuery: (query: string) => void, + }, +) { + const query = (props.args as { query?: unknown } | undefined)?.query; + const queryString = typeof query === "string" ? query : ""; + const result = props.result as { success?: unknown } | null | undefined; + const isSuccessful = props.status.type === "complete" && result?.success !== false; + const canApply = isSuccessful && queryString.trim().length > 0 && queryString !== props.currentQuery; + + return ( + e.stopPropagation()}> + + + + + ) : undefined} + /> + ); +} + +function AiQueryWelcome() { + return ( +
+
+
+ +
+

+ Build an analytics query +

+

+ Ask for a table, segment, trend, or funnel. Try “daily signups over the last 30 days” or “top 10 users by event count this week”. +

+
+
+ ); +} + +// ─── Save query sub-dialog ────────────────────────────────────────── + +function SaveQueryInlineDialog({ + open, + onOpenChange, + sqlQuery, +}: { + open: boolean, + onOpenChange: (open: boolean) => void, + sqlQuery: string, +}) { + const adminApp = useAdminApp(); + const project = adminApp.useProject(); + const config = project.useConfig(); + const updateConfig = useUpdateConfig(); + + const [displayName, setDisplayName] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open) { + setDisplayName(""); + setSaving(false); + } + }, [open]); + + const handleSave = useCallback(async () => { + if (!displayName.trim() || !sqlQuery.trim()) return; + setSaving(true); + try { + // Reuse an existing folder if available, otherwise create an + // "AI Queries" bucket on the fly so the save flow never stalls + // on folder management. + const existingFolders = Object.entries(config.analytics.queryFolders); + let folderId: string; + if (existingFolders.length > 0) { + folderId = existingFolders[0]![0]; + } else { + folderId = generateSecureRandomString(); + await updateConfig({ + adminApp, + configUpdate: { + [`analytics.queryFolders.${folderId}`]: { + displayName: "AI Queries", + sortOrder: 0, + queries: {}, + }, + }, + pushable: false, + }); + } + + const queryId = generateSecureRandomString(); + await updateConfig({ + adminApp, + configUpdate: { + [`analytics.queryFolders.${folderId}.queries.${queryId}`]: { + displayName: displayName.trim(), + sqlQuery, + }, + }, + pushable: false, + }); + onOpenChange(false); + } finally { + setSaving(false); + } + }, [displayName, sqlQuery, config, updateConfig, adminApp, onOpenChange]); + + return ( + + + + Save query + + +
+
+ + setDisplayName(e.target.value)} + placeholder="Recent signups" + onKeyDown={(e) => { + if (e.key === "Enter") { + runAsynchronouslyWithAlert(handleSave); + } + }} + /> +
+
+
+                {sqlQuery}
+              
+
+
+
+ + + + +
+
+ ); +} + +// ─── Build dashboard sub-dialog ───────────────────────────────────── + +/** + * Reuses the existing `CreateDashboardPreview` component (the same + * one the Cmd+K command center uses) so the dashboard-builder + * experience is identical whether you enter it from the command + * palette or from the analytics AI query builder. Most of + * `CmdKPreviewProps` are unused by `CreateDashboardPreview` internally, + * so we pass no-op stubs for them. + */ +function BuildDashboardDialog({ + open, + onOpenChange, + sqlQuery, +}: { + open: boolean, + onOpenChange: (open: boolean) => void, + sqlQuery: string, +}) { + // Synthesize a prompt that pre-seeds the SQL query as context so + // the dashboard the AI generates visualizes exactly these results. + const dashboardPrompt = useMemo( + () => + `Build a dashboard that visualizes the results of this ClickHouse query:\n\n\`\`\`sql\n${sqlQuery}\n\`\`\``, + [sqlQuery], + ); + + const stubProps: Omit = { + isSelected: true, + registerOnFocus: () => { + // no-op + }, + unregisterOnFocus: () => { + // no-op + }, + onBlur: () => { + // no-op + }, + registerNestedCommands: () => { + // no-op + }, + navigateToNested: () => { + // no-op + }, + depth: 0, + pathname: "", + }; + + return ( + + + + + + Build dashboard + + +
+ {open && ( + onOpenChange(false)} + {...stubProps} + /> + )} +
+
+
+ ); +} + +// ─── Main dialog ──────────────────────────────────────────────────── + +type AiQueryDialogProps = { + open: boolean, + onOpenChange: (open: boolean) => void, + chat: AiQueryChat, + /** The query currently driving the data grid (may be `null` if none yet). */ + currentQuery: string | null, +}; + +export function AiQueryDialog({ + open, + onOpenChange, + chat, + currentQuery, +}: AiQueryDialogProps) { + const [copied, setCopied] = useState(false); + const [saveOpen, setSaveOpen] = useState(false); + const [buildOpen, setBuildOpen] = useState(false); + const [currentQueryDraft, setCurrentQueryDraft] = useState(currentQuery ?? ""); + const assistantContentComponents = useMemo(() => ({ + Text: MarkdownText, + tools: { + Fallback: ToolFallback, + by_name: { + queryAnalytics: (props: ToolCallContentPartProps) => ( + + ), + }, + }, + }), [chat.rewindToQuery, currentQuery]); + + useEffect(() => { + setCurrentQueryDraft(currentQuery ?? ""); + }, [currentQuery]); + + const applyCurrentQueryDraft = useCallback(() => { + if (currentQueryDraft.trim().length === 0 || currentQueryDraft === currentQuery) return; + chat.rewindToQuery(currentQueryDraft); + }, [chat, currentQuery, currentQueryDraft]); + + const handleCopy = useCallback(async () => { + if (!currentQueryDraft) return; + await navigator.clipboard.writeText(currentQueryDraft); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, [currentQueryDraft]); + + const canActOnQuery = currentQueryDraft.trim().length > 0; + const hasUnappliedCurrentQueryEdits = currentQueryDraft.trim().length > 0 && currentQueryDraft !== (currentQuery ?? ""); + + return ( + <> + + + +
+ + + AI query builder + +
+ {chat.messages.length > 0 && ( + + + + )} + +
+
+
+ +
+
+ +
+ {hasUnappliedCurrentQueryEdits && ( + + + + )} + {canActOnQuery && ( + + + + )} +
+
+
+