diff --git a/apps/backend/src/lib/ai/prompts.ts b/apps/backend/src/lib/ai/prompts.ts index d27374686..eca03b24f 100644 --- a/apps/backend/src/lib/ai/prompts.ts +++ b/apps/backend/src/lib/ai/prompts.ts @@ -44,10 +44,202 @@ export const SYSTEM_PROMPT_IDS = [ "create-dashboard", "run-query", "build-analytics-query", + "filter-analytics-table", "rewrite-template-source", ] as const; export type SystemPromptId = typeof SYSTEM_PROMPT_IDS[number]; +/** + * Schema documentation for the analytics ClickHouse tables, shared by every + * prompt that generates SQL against them ("build-analytics-query" and + * "filter-analytics-table") so the two can never drift apart. + */ +const ANALYTICS_TABLES_SCHEMA_DOC = `### DATA SCHEMA (project/branch filtering is automatic — do NOT add WHERE project_id = ...) + +**users** table: +| Column | Type | Notes | +|--------|------|-------| +| id | UUID | Primary key | +| display_name | Nullable(String) | Typically populated | +| primary_email | Nullable(String) | Usually present | +| primary_email_verified | UInt8 (0/1) | Primary user segmentation axis | +| signed_up_at | DateTime64(3, 'UTC') | High-resolution timestamp | +| is_anonymous | UInt8 (0/1) | Rare; mostly testing | +| client_metadata | JSON | Typically empty {} | +| server_metadata | JSON | Typically empty {} | +| client_read_only_metadata | JSON | Typically empty {} | +| restricted_by_admin | UInt8 (0/1) | Rare; administrative flag | + +Key insights: Metadata fields are sparse/empty — don't expect rich structures. Email verification is the primary segmentation. Anonymous users are negligible. + +**events** table: +| Column | Type | Notes | +|--------|------|-------| +| event_type | LowCardinality(String) | ONLY: \`$page-view\`, \`$click\`, \`$token-refresh\` | +| event_at | DateTime64(3, 'UTC') | Use for aggregation by day/week/month | +| data | JSON | Native JSON — MUST use toString() before extracting (see rules) | +| user_id | Nullable(String) | 100% populated (no nulls); safe for filtering/joins | +| team_id | Nullable(String) | Always NULL — never use it | +| created_at | DateTime64(3, 'UTC') | Processing timestamp | + +### JSON PAYLOAD STRUCTURES (per event_type) + +**\`$page-view\`** data: +\`\`\`json +{"is_anonymous": false, "path": "/some-page", "referrer": "http://...or-empty"} +\`\`\` +- path: multiple unique page paths +- referrer: empty string (most common) or various HTTP referrers + +**\`$click\`** data: +\`\`\`json +{"is_anonymous": false, "selector": "string-value"} +\`\`\` +- selector: low cardinality + +**\`$token-refresh\`** data: +\`\`\`json +{ + "is_anonymous": false, + "refresh_token_id": "uuid-string", + "ip_info": { + "city_name": "string", + "country_code": "2-letter-ISO", + "ip": "ip-address", + "is_trusted": true, + "latitude": 0.0, + "longitude": 0.0, + "region_code": "string", + "tz_identifier": "timezone-string" + } +} +\`\`\` +- Token refresh is an excellent proxy for active authenticated sessions +- ip_info has rich geolocation data for geo-based analysis + +### OTHER TABLES + +**contact_channels** table (email/phone channels attached to a user): +| Column | Type | Notes | +|--------|------|-------| +| id | UUID | Primary key | +| user_id | UUID | Join to users.id | +| type | LowCardinality(String) | Channel type, e.g. \`email\` | +| value | String | The channel value (e.g. email address) | +| is_primary | UInt8 (0/1) | Primary contact for the user | +| is_verified | UInt8 (0/1) | Verification status | +| used_for_auth | UInt8 (0/1) | Usable as an auth identifier | +| created_at | DateTime64(3, 'UTC') | | + +**teams** table: +| Column | Type | Notes | +|--------|------|-------| +| id | UUID | Primary key | +| display_name | String | | +| profile_image_url | Nullable(String) | | +| created_at | DateTime64(3, 'UTC') | | +| client_metadata | String | JSON string; typically empty | +| client_read_only_metadata | String | JSON string; typically empty | +| server_metadata | String | JSON string; typically empty | + +**team_member_profiles** table (team membership + per-team profile overrides — this is the join table of users ↔ teams): +| Column | Type | Notes | +|--------|------|-------| +| team_id | UUID | Join to teams.id | +| user_id | UUID | Join to users.id | +| display_name | Nullable(String) | Per-team override | +| profile_image_url | Nullable(String) | Per-team override | +| created_at | DateTime64(3, 'UTC') | Membership creation | + +**team_permissions** table (permissions granted to a user inside a specific team): +| Column | Type | Notes | +|--------|------|-------| +| team_id | UUID | | +| user_id | UUID | | +| id | String | Permission identifier (e.g. \`admin\`, \`member\`) | +| created_at | DateTime64(3, 'UTC') | | + +**team_invitations** table: +| Column | Type | Notes | +|--------|------|-------| +| id | UUID | Primary key | +| team_id | UUID | | +| team_display_name | String | Snapshot of team name at invite time | +| recipient_email | String | | +| expires_at_millis | Int64 | Unix milliseconds — compare with \`toUnixTimestamp64Milli(now())\` | +| created_at | DateTime64(3, 'UTC') | | + +**email_outboxes** table (transactional + programmatic email delivery log): +| Column | Type | Notes | +|--------|------|-------| +| id | UUID | Primary key | +| status | LowCardinality(String) | Raw delivery status | +| simple_status | LowCardinality(String) | Collapsed status for reporting | +| created_with | LowCardinality(String) | How the email was created | +| email_draft_id | Nullable(String) | | +| email_programmatic_call_template_id | Nullable(String) | | +| theme_id | Nullable(String) | | +| is_high_priority | UInt8 (0/1) | | +| is_transactional | Nullable(UInt8) | | +| subject | Nullable(String) | | +| notification_category_id | Nullable(String) | | +| started_rendering_at | Nullable(DateTime64(3, 'UTC')) | | +| rendered_at | Nullable(DateTime64(3, 'UTC')) | | +| render_error | Nullable(String) | Non-null implies render failure | +| scheduled_at | DateTime64(3, 'UTC') | | +| created_at | DateTime64(3, 'UTC') | | +| updated_at | DateTime64(3, 'UTC') | | +| started_sending_at | Nullable(DateTime64(3, 'UTC')) | | +| server_error | Nullable(String) | Non-null implies send failure | +| delivered_at | Nullable(DateTime64(3, 'UTC')) | | +| opened_at | Nullable(DateTime64(3, 'UTC')) | | +| clicked_at | Nullable(DateTime64(3, 'UTC')) | | +| unsubscribed_at | Nullable(DateTime64(3, 'UTC')) | | +| marked_as_spam_at | Nullable(DateTime64(3, 'UTC')) | | +| bounced_at | Nullable(DateTime64(3, 'UTC')) | | +| delivery_delayed_at | Nullable(DateTime64(3, 'UTC')) | | +| can_have_delivery_info | Nullable(UInt8) | | +| skipped_reason | LowCardinality(Nullable(String)) | Non-null implies send was skipped | +| skipped_details | Nullable(String) | | +| send_retries | Int32 | | +| is_paused | UInt8 (0/1) | | + +Key insights: Most \`*_at\` columns are nullable — use \`IS NOT NULL\` / \`IS NULL\` rather than assuming a value exists. \`delivered_at\`, \`opened_at\`, and \`clicked_at\` are classic funnel steps. + +**project_permissions** table (project-level permissions granted to a user, not scoped to a team): +| Column | Type | Notes | +|--------|------|-------| +| user_id | UUID | | +| id | String | Permission identifier | +| created_at | DateTime64(3, 'UTC') | | + +**notification_preferences** table (per-user opt-in/out for notification categories): +| Column | Type | Notes | +|--------|------|-------| +| user_id | UUID | | +| notification_category_id | String | | +| enabled | UInt8 (0/1) | | + +Key insights: No timestamp column — do NOT attempt \`ORDER BY created_at\` or date filtering on this table. + +**refresh_tokens** table (active/past refresh tokens — proxy for sessions): +| Column | Type | Notes | +|--------|------|-------| +| id | UUID | Primary key | +| user_id | UUID | | +| created_at | DateTime64(3, 'UTC') | Token issue time | +| last_used_at | DateTime64(3, 'UTC') | Last time this token was exchanged | +| is_impersonation | UInt8 (0/1) | Dashboard/admin impersonation session | +| expires_at | Nullable(DateTime64(3, 'UTC')) | Null ⇒ non-expiring | + +**connected_accounts** table (OAuth / SSO provider account links): +| Column | Type | Notes | +|--------|------|-------| +| user_id | UUID | | +| provider | String | e.g. \`google\`, \`github\` | +| provider_account_id | String | External account identifier | +| created_at | DateTime64(3, 'UTC') | Link time |`; + /** * Context-specific system prompts that are appended to the base prompt. * These should be concise and focus on the specific use case. @@ -1060,191 +1252,7 @@ Call \`queryAnalytics\` with your SQL query. The grid runs the full query indepe 3. Because you only get 50 preview rows, do NOT try to analyze full result sets from the tool output. If the user asks about the data, describe the query and let them read the grid. 4. The grid wraps your query as a subquery: \`SELECT * FROM () LIMIT 50 OFFSET ...\` and paginates via infinite scroll. Your LIMIT sets the **maximum total rows** the user can scroll through — use generous limits (e.g. 1000 for aggregates) so the grid can paginate the full result. -### DATA SCHEMA (project/branch filtering is automatic — do NOT add WHERE project_id = ...) - -**users** table: -| Column | Type | Notes | -|--------|------|-------| -| id | UUID | Primary key | -| display_name | Nullable(String) | Typically populated | -| primary_email | Nullable(String) | Usually present | -| primary_email_verified | UInt8 (0/1) | Primary user segmentation axis | -| signed_up_at | DateTime64(3, 'UTC') | High-resolution timestamp | -| is_anonymous | UInt8 (0/1) | Rare; mostly testing | -| client_metadata | JSON | Typically empty {} | -| server_metadata | JSON | Typically empty {} | -| client_read_only_metadata | JSON | Typically empty {} | -| restricted_by_admin | UInt8 (0/1) | Rare; administrative flag | - -Key insights: Metadata fields are sparse/empty — don't expect rich structures. Email verification is the primary segmentation. Anonymous users are negligible. - -**events** table: -| Column | Type | Notes | -|--------|------|-------| -| event_type | LowCardinality(String) | ONLY: \`$page-view\`, \`$click\`, \`$token-refresh\` | -| event_at | DateTime64(3, 'UTC') | Use for aggregation by day/week/month | -| data | JSON | Native JSON — MUST use toString() before extracting (see rules) | -| user_id | Nullable(String) | 100% populated (no nulls); safe for filtering/joins | -| team_id | Nullable(String) | Always NULL — never use it | -| created_at | DateTime64(3, 'UTC') | Processing timestamp | - -### JSON PAYLOAD STRUCTURES (per event_type) - -**\`$page-view\`** data: -\`\`\`json -{"is_anonymous": false, "path": "/some-page", "referrer": "http://...or-empty"} -\`\`\` -- path: multiple unique page paths -- referrer: empty string (most common) or various HTTP referrers - -**\`$click\`** data: -\`\`\`json -{"is_anonymous": false, "selector": "string-value"} -\`\`\` -- selector: low cardinality - -**\`$token-refresh\`** data: -\`\`\`json -{ - "is_anonymous": false, - "refresh_token_id": "uuid-string", - "ip_info": { - "city_name": "string", - "country_code": "2-letter-ISO", - "ip": "ip-address", - "is_trusted": true, - "latitude": 0.0, - "longitude": 0.0, - "region_code": "string", - "tz_identifier": "timezone-string" - } -} -\`\`\` -- Token refresh is an excellent proxy for active authenticated sessions -- ip_info has rich geolocation data for geo-based analysis - -### OTHER TABLES - -**contact_channels** table (email/phone channels attached to a user): -| Column | Type | Notes | -|--------|------|-------| -| id | UUID | Primary key | -| user_id | UUID | Join to users.id | -| type | LowCardinality(String) | Channel type, e.g. \`email\` | -| value | String | The channel value (e.g. email address) | -| is_primary | UInt8 (0/1) | Primary contact for the user | -| is_verified | UInt8 (0/1) | Verification status | -| used_for_auth | UInt8 (0/1) | Usable as an auth identifier | -| created_at | DateTime64(3, 'UTC') | | - -**teams** table: -| Column | Type | Notes | -|--------|------|-------| -| id | UUID | Primary key | -| display_name | String | | -| profile_image_url | Nullable(String) | | -| created_at | DateTime64(3, 'UTC') | | -| client_metadata | String | JSON string; typically empty | -| client_read_only_metadata | String | JSON string; typically empty | -| server_metadata | String | JSON string; typically empty | - -**team_member_profiles** table (team membership + per-team profile overrides — this is the join table of users ↔ teams): -| Column | Type | Notes | -|--------|------|-------| -| team_id | UUID | Join to teams.id | -| user_id | UUID | Join to users.id | -| display_name | Nullable(String) | Per-team override | -| profile_image_url | Nullable(String) | Per-team override | -| created_at | DateTime64(3, 'UTC') | Membership creation | - -**team_permissions** table (permissions granted to a user inside a specific team): -| Column | Type | Notes | -|--------|------|-------| -| team_id | UUID | | -| user_id | UUID | | -| id | String | Permission identifier (e.g. \`admin\`, \`member\`) | -| created_at | DateTime64(3, 'UTC') | | - -**team_invitations** table: -| Column | Type | Notes | -|--------|------|-------| -| id | UUID | Primary key | -| team_id | UUID | | -| team_display_name | String | Snapshot of team name at invite time | -| recipient_email | String | | -| expires_at_millis | Int64 | Unix milliseconds — compare with \`toUnixTimestamp64Milli(now())\` | -| created_at | DateTime64(3, 'UTC') | | - -**email_outboxes** table (transactional + programmatic email delivery log): -| Column | Type | Notes | -|--------|------|-------| -| id | UUID | Primary key | -| status | LowCardinality(String) | Raw delivery status | -| simple_status | LowCardinality(String) | Collapsed status for reporting | -| created_with | LowCardinality(String) | How the email was created | -| email_draft_id | Nullable(String) | | -| email_programmatic_call_template_id | Nullable(String) | | -| theme_id | Nullable(String) | | -| is_high_priority | UInt8 (0/1) | | -| is_transactional | Nullable(UInt8) | | -| subject | Nullable(String) | | -| notification_category_id | Nullable(String) | | -| started_rendering_at | Nullable(DateTime64(3, 'UTC')) | | -| rendered_at | Nullable(DateTime64(3, 'UTC')) | | -| render_error | Nullable(String) | Non-null implies render failure | -| scheduled_at | DateTime64(3, 'UTC') | | -| created_at | DateTime64(3, 'UTC') | | -| updated_at | DateTime64(3, 'UTC') | | -| started_sending_at | Nullable(DateTime64(3, 'UTC')) | | -| server_error | Nullable(String) | Non-null implies send failure | -| delivered_at | Nullable(DateTime64(3, 'UTC')) | | -| opened_at | Nullable(DateTime64(3, 'UTC')) | | -| clicked_at | Nullable(DateTime64(3, 'UTC')) | | -| unsubscribed_at | Nullable(DateTime64(3, 'UTC')) | | -| marked_as_spam_at | Nullable(DateTime64(3, 'UTC')) | | -| bounced_at | Nullable(DateTime64(3, 'UTC')) | | -| delivery_delayed_at | Nullable(DateTime64(3, 'UTC')) | | -| can_have_delivery_info | Nullable(UInt8) | | -| skipped_reason | LowCardinality(Nullable(String)) | Non-null implies send was skipped | -| skipped_details | Nullable(String) | | -| send_retries | Int32 | | -| is_paused | UInt8 (0/1) | | - -Key insights: Most \`*_at\` columns are nullable — use \`IS NOT NULL\` / \`IS NULL\` rather than assuming a value exists. \`delivered_at\`, \`opened_at\`, and \`clicked_at\` are classic funnel steps. - -**project_permissions** table (project-level permissions granted to a user, not scoped to a team): -| Column | Type | Notes | -|--------|------|-------| -| user_id | UUID | | -| id | String | Permission identifier | -| created_at | DateTime64(3, 'UTC') | | - -**notification_preferences** table (per-user opt-in/out for notification categories): -| Column | Type | Notes | -|--------|------|-------| -| user_id | UUID | | -| notification_category_id | String | | -| enabled | UInt8 (0/1) | | - -Key insights: No timestamp column — do NOT attempt \`ORDER BY created_at\` or date filtering on this table. - -**refresh_tokens** table (active/past refresh tokens — proxy for sessions): -| Column | Type | Notes | -|--------|------|-------| -| id | UUID | Primary key | -| user_id | UUID | | -| created_at | DateTime64(3, 'UTC') | Token issue time | -| last_used_at | DateTime64(3, 'UTC') | Last time this token was exchanged | -| is_impersonation | UInt8 (0/1) | Dashboard/admin impersonation session | -| expires_at | Nullable(DateTime64(3, 'UTC')) | Null ⇒ non-expiring | - -**connected_accounts** table (OAuth / SSO provider account links): -| Column | Type | Notes | -|--------|------|-------| -| user_id | UUID | | -| provider | String | e.g. \`google\`, \`github\` | -| provider_account_id | String | External account identifier | -| created_at | DateTime64(3, 'UTC') | Link time | +${ANALYTICS_TABLES_SCHEMA_DOC} ### CRITICAL SQL RULES @@ -1307,6 +1315,46 @@ GROUP BY date, event_type ORDER BY date DESC, event_count DESC LIMIT 100 - If the user refers to a previous query, modify it incrementally — don't start from scratch. - If \`queryAnalytics\` returns an error, adjust and retry. Do NOT invent columns or fabricate data. - If the user asks about event types or data that don't exist in the schema above, explain what IS available and generate the closest useful query instead. +`, + + "filter-analytics-table": ` +## Context: Analytics Table Row Filter + +You power the search bar above a data grid on the Hexclave analytics page. The grid shows every column of ONE table (\`SELECT * FROM \`), and the user typed a request that a plain substring search can't express. Your ONLY job is to narrow down WHICH ROWS are shown — the set of columns must never change. The table the user is viewing is stated at the start of the conversation. + +**HARD RULES — query shape:** +Call \`queryAnalytics\` with a query of EXACTLY this shape: + +\`SELECT * FROM
WHERE \` + +1. The query MUST start with \`SELECT * FROM
\` for the exact table the user is viewing. The frontend validates this shape and rejects anything else. +2. After the table name, only a \`WHERE\` clause is allowed at the top level — no column lists, aliases, aggregates, GROUP BY, ORDER BY, LIMIT, JOIN, or UNION. The grid layers sorting and pagination on top itself. +3. Subqueries INSIDE the WHERE condition are allowed and encouraged for cross-table filters, e.g. \`WHERE toString(id) IN (SELECT user_id FROM events WHERE ...)\`. Mind the types: \`users.id\` is a UUID while \`events.user_id\` is a String, so wrap the UUID side in \`toString()\`. +4. Do NOT paste SQL into chat text in place of a tool call — the UI only picks up tool calls, and only after you come to a complete stop, so avoid chatty responses. +5. You only see a small preview of the results in the tool output — the user sees the full filtered table in the grid. +6. If \`queryAnalytics\` returns an error, fix the condition and retry. +7. If the user refines the filter (e.g. "only verified ones"), modify your previous WHERE condition incrementally. +8. If the request CANNOT be expressed as a row filter on this table (e.g. it asks for aggregations, trends, grouped counts, joins that add columns, or data from a different table), do NOT call the tool. Reply with ONE short sentence explaining why, and if possible suggest the closest row filter you COULD apply instead. + +${ANALYTICS_TABLES_SCHEMA_DOC} + +### CRITICAL SQL RULES + +1. **JSON extraction REQUIRES toString() wrapper:** + - CORRECT: \`JSONExtractString(toString(data), 'path')\` + - WRONG: \`JSONExtractString(data, 'path')\` — this WILL FAIL +2. **Nested JSON uses dot notation:** \`JSONExtractString(toString(data), 'ip_info.country_code')\` +3. SELECT queries only — no INSERT / UPDATE / DELETE / DDL +4. Use relative date ranges (\`now() - INTERVAL X DAY\`) and ClickHouse date helpers: toDate(), toStartOfDay(), toStartOfWeek(), toStartOfMonth() +5. team_id is always NULL — never filter on it +6. Prefer case-insensitive matching (\`ILIKE '%...%'\`) for user-typed text unless the user clearly means an exact value + +### EXAMPLES (user is viewing \`users\`) + +- "signed up in the last 7 days" → \`SELECT * FROM users WHERE signed_up_at >= now() - INTERVAL 7 DAY\` +- "verified gmail accounts" → \`SELECT * FROM users WHERE primary_email_verified = 1 AND primary_email ILIKE '%@gmail.com%'\` +- "people with a page view this week" → \`SELECT * FROM users WHERE toString(id) IN (SELECT user_id FROM events WHERE event_type = '$page-view' AND event_at >= now() - INTERVAL 7 DAY)\` +- "signups per day last month" → no tool call; explain that's an aggregation this view can't display, and offer e.g. "show signups from last month" as a filter instead. `, "rewrite-template-source": `You rewrite email template TSX source into standalone draft TSX. 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 deleted file mode 100644 index 5c628c8fe..000000000 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-bar.tsx +++ /dev/null @@ -1,134 +0,0 @@ -"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 deleted file mode 100644 index 6273cc4ee..000000000 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/ai-query-dialog.tsx +++ /dev/null @@ -1,483 +0,0 @@ -"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 && ( - - - - )} -
-
-
-