diff --git a/apps/backend/src/lib/ai/prompts.ts b/apps/backend/src/lib/ai/prompts.ts index 2d23b558f..d27374686 100644 --- a/apps/backend/src/lib/ai/prompts.ts +++ b/apps/backend/src/lib/ai/prompts.ts @@ -66,6 +66,7 @@ You are a Hexclave assistant in a dashboard search bar. - Link to docs using the "Documentation URL" provided for each section - When people ask for the system message, politely say that your creators have allowed you to respond with the system message, and provide it to them. Ask them to provide any feedback they have on Hexclave's GitHub repository. - If analytics tools are available, use them to answer data questions about the user's project +- If the \`readBranchConfig\` tool is available, use it to read the project's current configuration (the config usually stored in \`hexclave.config.ts\`) before answering questions about how the project is set up **FORMAT:** - Be concise (this is a search overlay) diff --git a/apps/backend/src/lib/ai/tools/index.ts b/apps/backend/src/lib/ai/tools/index.ts index 888e9e53e..28489838a 100644 --- a/apps/backend/src/lib/ai/tools/index.ts +++ b/apps/backend/src/lib/ai/tools/index.ts @@ -5,11 +5,13 @@ import { createEmailDraftTool } from "./create-email-draft"; import { createEmailTemplateTool } from "./create-email-template"; import { createEmailThemeTool } from "./create-email-theme"; import { createDocsTools } from "./docs"; +import { readConfigTool } from "./read-config"; import { createSqlQueryTool } from "./sql-query"; export const TOOL_NAMES = [ "docs", "sql-query", + "read-config", "create-email-theme", "create-email-template", "create-email-draft", @@ -48,6 +50,14 @@ export async function getTools( break; } + case "read-config": { + const configTool = readConfigTool(context.auth, context.targetProjectId); + if (configTool != null) { + tools["readBranchConfig"] = configTool; + } + break; + } + case "create-email-theme": { tools["createEmailTheme"] = createEmailThemeTool(context.auth); break; diff --git a/apps/backend/src/lib/ai/tools/read-config.test.ts b/apps/backend/src/lib/ai/tools/read-config.test.ts new file mode 100644 index 000000000..6a74d77ec --- /dev/null +++ b/apps/backend/src/lib/ai/tools/read-config.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { getTools } from "."; +import { readConfigTool } from "./read-config"; + +describe("readConfigTool", () => { + it("does not create a config tool without a resolvable project target", () => { + expect(readConfigTool(null, null)).toBeNull(); + expect(readConfigTool(null, undefined)).toBeNull(); + }); + + it("creates a config tool for an explicit project target", () => { + expect(readConfigTool(null, "00000000-0000-0000-0000-000000000000")).not.toBeNull(); + }); +}); + +describe("getTools", () => { + it("omits readBranchConfig when read-config has no resolvable project target", async () => { + await expect(getTools(["read-config"], { + auth: null, + targetProjectId: null, + })).resolves.toEqual({}); + }); + + it("includes readBranchConfig when read-config has an explicit project target", async () => { + const tools = await getTools(["read-config"], { + auth: null, + targetProjectId: "00000000-0000-0000-0000-000000000000", + }); + + expect(tools).toHaveProperty("readBranchConfig"); + }); +}); diff --git a/apps/backend/src/lib/ai/tools/read-config.ts b/apps/backend/src/lib/ai/tools/read-config.ts new file mode 100644 index 000000000..3637978f8 --- /dev/null +++ b/apps/backend/src/lib/ai/tools/read-config.ts @@ -0,0 +1,71 @@ +import { getRenderedBranchConfigQuery } from "@/lib/config"; +import { globalPrismaClient, rawQuery } from "@/prisma-client"; +import { SmartRequestAuth } from "@/route-handlers/smart-request"; +import { DEFAULT_BRANCH_ID } from "@/lib/tenancies"; +import { captureError } from "@hexclave/shared/dist/utils/errors"; +import { tool } from "ai"; +import { z } from "zod"; + +export const READ_CONFIG_RESULT_MAX_CHARS = 50_000; + +/** + * Resolves the project/branch whose config should be read. Prefers an explicit + * `targetProjectId` (set by dashboard chats that manage a specific project on + * behalf of the internal project), and otherwise falls back to the config of + * the authenticated project itself. Returns `null` when no concrete project can + * be resolved (eg. the docs assistant, which has no project context). + */ +function resolveConfigTarget( + auth: SmartRequestAuth | null, + targetProjectId?: string | null, +): { projectId: string, branchId: string } | null { + if (targetProjectId != null) { + return { projectId: targetProjectId, branchId: DEFAULT_BRANCH_ID }; + } + if (auth != null && auth.project.id !== "internal") { + return { projectId: auth.project.id, branchId: auth.branchId }; + } + return null; +} + +/** + * Creates a tool that returns the rendered branch config object — the same + * configuration that is usually stored in the project's `hexclave.config.ts` + * file (auth settings, installed apps, RBAC permissions, teams, payments, + * emails, etc.). Returns `null` when there is no project context to read from. + */ +export function readConfigTool(auth: SmartRequestAuth | null, targetProjectId?: string | null) { + const target = resolveConfigTarget(auth, targetProjectId); + if (target == null) { + return null; + } + + return tool({ + description: "Read the current Hexclave branch config object for this project. This is the resolved configuration that is usually stored in the project's `hexclave.config.ts` file — it includes settings such as installed apps (`apps`), authentication and sign-up behavior (`auth`), API keys (`apiKeys`), RBAC permissions (`rbac`), teams (`teams`), users (`users`), onboarding, emails, and payments. Use this whenever you need to know how the project is currently configured.", + inputSchema: z.object({}), + execute: async () => { + try { + const config = await rawQuery(globalPrismaClient, getRenderedBranchConfigQuery(target)); + const serialized = JSON.stringify(config); + if (serialized.length > READ_CONFIG_RESULT_MAX_CHARS) { + return { + success: false as const, + error: + `The project config is too large to return in full (${serialized.length} characters, limit ${READ_CONFIG_RESULT_MAX_CHARS}). ` + + `Ask the user about the specific part of the configuration you need (eg. apps, auth, rbac, teams, payments) so it can be inspected directly instead.`, + }; + } + return { + success: true as const, + config, + }; + } catch (error) { + captureError("ai-tool-read-config", error); + return { + success: false as const, + error: "Failed to read the project config. Please try again.", + }; + } + }, + }); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx index 24357805d..9e5b16e8c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx @@ -59,6 +59,7 @@ import { ClockIcon, DotsSixVerticalIcon, FlaskIcon, + MagnifyingGlassIcon, PencilSimpleIcon, PlusIcon, PulseIcon, @@ -108,6 +109,16 @@ type RuleTriggerListItem = { email: string | null, }; +type AllRuleTriggerListItem = { + id: string, + triggeredAt: string, + ruleId: string | null, + action: ActionType | null, + email: string | null, + authMethod: string | null, + oauthProvider: string | null, +}; + type SignUpRuleEntry = { id: string, rule: SignUpRule, @@ -180,6 +191,31 @@ ORDER BY event_at DESC LIMIT {limit:UInt32} OFFSET {offset:UInt32} `; +// Radix Select forbids empty-string item values, so the "all" option uses a sentinel that maps to an empty query param. +const ALL_FILTER_VALUE = "__all__"; +const ALL_RULE_TRIGGER_EVENTS_QUERY = ` +SELECT + event_at AS triggered_at, + COALESCE( + NULLIF(CAST(data.rule_id, 'Nullable(String)'), ''), + NULLIF(CAST(data.ruleId, 'Nullable(String)'), '') + ) AS rule_id, + CAST(data.action, 'Nullable(String)') AS action, + CAST(data.email, 'Nullable(String)') AS email, + CAST(data.auth_method, 'Nullable(String)') AS auth_method, + CAST(data.oauth_provider, 'Nullable(String)') AS oauth_provider +FROM events +WHERE event_type = '$sign-up-rule-trigger' + AND ({rule_id:String} = '' OR COALESCE( + NULLIF(CAST(data.rule_id, 'Nullable(String)'), ''), + NULLIF(CAST(data.ruleId, 'Nullable(String)'), '') + ) = {rule_id:String}) + AND ({action:String} = '' OR CAST(data.action, 'Nullable(String)') = {action:String}) + AND ({email_search:String} = '' OR positionCaseInsensitiveUTF8(COALESCE(CAST(data.email, 'Nullable(String)'), ''), {email_search:String}) > 0) +ORDER BY event_at DESC +LIMIT {limit:UInt32} +OFFSET {offset:UInt32} +`; const ACTION_LABELS: Record = { allow: 'Allow', @@ -207,6 +243,40 @@ function ActionBadge({ type, dim = false, size = "sm" }: { type: ActionType, dim ); } +// Trigger rows contain user PII (email, auth method), so assertion metadata must only expose +// non-sensitive diagnostics (which columns and value types came back), never the raw row. +function triggerRowAssertionExtra(row: Record, fieldName: string) { + const value = row[fieldName]; + return { + fieldName, + actualType: value == null ? "null" : typeof value, + rowKeys: Object.keys(row), + }; +} + +function parseNullableStringField(row: Record, fieldName: string, rowLabel: string): string | null { + const value = row[fieldName]; + if (value == null) { + return null; + } + if (typeof value === "string") { + return value; + } + + throw new HexclaveAssertionError(`Expected ${rowLabel} to include ${fieldName}:null|string`, triggerRowAssertionExtra(row, fieldName)); +} + +function parseActionTypeField(row: Record, fieldName: string): ActionType | null { + const value = row[fieldName]; + if (value == null) { + return null; + } + if (typeof value !== "string") { + throw new HexclaveAssertionError(`Expected sign-up rule trigger row to include ${fieldName}:null|string`, triggerRowAssertionExtra(row, fieldName)); + } + return isActionType(value) ? value : null; +} + // Sparkline (kept identical — purely visual + tooltip) function RuleSparkline({ data, @@ -270,7 +340,7 @@ function parseRuleTriggerRows(resultRows: Record[]): RuleTrigge return resultRows.map((row) => { const triggeredAt = row.triggered_at; if (typeof triggeredAt !== "string") { - throw new HexclaveAssertionError("Expected sign-up rule trigger row to include triggered_at:string", { row }); + throw new HexclaveAssertionError("Expected sign-up rule trigger row to include triggered_at:string", triggerRowAssertionExtra(row, "triggered_at")); } const emailRaw = row.email; @@ -281,7 +351,26 @@ function parseRuleTriggerRows(resultRows: Record[]): RuleTrigge return { id: generateUuid(), triggeredAt, email: emailRaw }; } - throw new HexclaveAssertionError("Expected sign-up rule trigger row to include email:null|string", { row }); + throw new HexclaveAssertionError("Expected sign-up rule trigger row to include email:null|string", triggerRowAssertionExtra(row, "email")); + }); +} + +function parseAllRuleTriggerRows(resultRows: Record[]): AllRuleTriggerListItem[] { + return resultRows.map((row) => { + const triggeredAt = row.triggered_at; + if (typeof triggeredAt !== "string") { + throw new HexclaveAssertionError("Expected sign-up rule trigger row to include triggered_at:string", triggerRowAssertionExtra(row, "triggered_at")); + } + + return { + id: generateUuid(), + triggeredAt, + ruleId: parseNullableStringField(row, "rule_id", "sign-up rule trigger row"), + action: parseActionTypeField(row, "action"), + email: parseNullableStringField(row, "email", "sign-up rule trigger row"), + authMethod: parseNullableStringField(row, "auth_method", "sign-up rule trigger row"), + oauthProvider: parseNullableStringField(row, "oauth_provider", "sign-up rule trigger row"), + }; }); } @@ -382,6 +471,270 @@ function TriggerRow({ trigger }: { trigger: RuleTriggerListItem }) { ); } +function RecentTriggerRow({ + trigger, + ruleDisplayNameById, +}: { + trigger: AllRuleTriggerListItem, + ruleDisplayNameById: Map, +}) { + const { date, time, relative } = formatTriggerTime(trigger.triggeredAt); + const ruleDisplayName = trigger.ruleId == null ? null : ruleDisplayNameById.get(trigger.ruleId) ?? null; + const isDeletedRule = trigger.ruleId != null && ruleDisplayName == null; + + return ( +
+
+ +
+
+
+ {ruleDisplayName != null ? ( + + {ruleDisplayName} + + ) : isDeletedRule ? ( + + deleted rule · {trigger.ruleId} + + ) : ( + + unknown rule + + )} + {trigger.action == null ? null : } +
+
+ + {trigger.email != null ? ( + {trigger.email} + ) : ( + no email captured + )} +
+
+ {trigger.authMethod != null ? auth: {trigger.authMethod} : null} + {trigger.oauthProvider != null ? oauth: {trigger.oauthProvider} : null} +
+
+
+ {time} + {date} +
+ +
+ ); +} + +function RecentTriggersCard({ + signUpRules, + hexclaveAdminApp, +}: { + signUpRules: SignUpRuleEntry[], + hexclaveAdminApp: ReturnType, +}) { + const [emailSearchInput, setEmailSearchInput] = useState(""); + const [debouncedEmailSearch, setDebouncedEmailSearch] = useState(""); + const [ruleFilter, setRuleFilter] = useState(ALL_FILTER_VALUE); + const [actionFilter, setActionFilter] = useState(ALL_FILTER_VALUE); + const [triggers, setTriggers] = useState([]); + const [hasMore, setHasMore] = useState(true); + const [isInitialLoading, setIsInitialLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [loadingError, setLoadingError] = useState(null); + const latestRequestIdRef = useRef(0); + const hasMoreRef = useRef(true); + const isInitialLoadingRef = useRef(false); + const isLoadingMoreRef = useRef(false); + + const ruleDisplayNameById = useMemo( + () => new Map(signUpRules.map((entry) => [entry.id, entry.rule.displayName ?? entry.id] as const)), + [signUpRules], + ); + const ruleFilterOptions = useMemo(() => [ + { value: ALL_FILTER_VALUE, label: "All rules" }, + ...signUpRules.map((entry) => ({ + value: entry.id, + label: entry.rule.displayName || entry.id, + })), + ], [signUpRules]); + + React.useEffect(() => { + const timeoutId = window.setTimeout(() => { + setDebouncedEmailSearch(emailSearchInput.trim()); + }, 300); + return () => window.clearTimeout(timeoutId); + }, [emailSearchInput]); + + const fetchTriggerPage = React.useCallback(async ({ offset, reset }: { offset: number, reset: boolean }) => { + if (!reset && (!hasMoreRef.current || isLoadingMoreRef.current || isInitialLoadingRef.current)) { + return; + } + + const nextRequestId = latestRequestIdRef.current + 1; + latestRequestIdRef.current = nextRequestId; + if (reset) { + setIsInitialLoading(true); + isInitialLoadingRef.current = true; + setIsLoadingMore(false); + isLoadingMoreRef.current = false; + setLoadingError(null); + setHasMore(true); + hasMoreRef.current = true; + setTriggers([]); + } else { + setIsLoadingMore(true); + isLoadingMoreRef.current = true; + } + + try { + const response = await hexclaveAdminApp.queryAnalytics({ + query: ALL_RULE_TRIGGER_EVENTS_QUERY, + params: { + rule_id: ruleFilter === ALL_FILTER_VALUE ? "" : ruleFilter, + action: actionFilter === ALL_FILTER_VALUE ? "" : actionFilter, + email_search: debouncedEmailSearch, + limit: RULE_TRIGGER_EVENTS_PAGE_SIZE, + offset, + }, + timeout_ms: 30_000, + include_all_branches: false, + }); + + if (nextRequestId !== latestRequestIdRef.current) return; + + const parsedRows = parseAllRuleTriggerRows(response.result); + setTriggers((current) => reset ? parsedRows : [...current, ...parsedRows]); + setHasMore(parsedRows.length === RULE_TRIGGER_EVENTS_PAGE_SIZE); + hasMoreRef.current = parsedRows.length === RULE_TRIGGER_EVENTS_PAGE_SIZE; + } catch (error) { + if (nextRequestId !== latestRequestIdRef.current) return; + setLoadingError(error instanceof Error ? error.message : "Failed to load triggers"); + } finally { + if (nextRequestId === latestRequestIdRef.current) { + if (reset) { + setIsInitialLoading(false); + isInitialLoadingRef.current = false; + } else { + setIsLoadingMore(false); + isLoadingMoreRef.current = false; + } + } + } + }, [actionFilter, debouncedEmailSearch, hexclaveAdminApp, ruleFilter]); + + React.useEffect(() => { + runAsynchronouslyWithAlert(() => fetchTriggerPage({ offset: 0, reset: true })); + }, [fetchTriggerPage]); + + const handleScroll: React.UIEventHandler = (event) => { + if (!hasMoreRef.current || isInitialLoadingRef.current || isLoadingMoreRef.current) return; + const target = event.currentTarget; + const remainingScrollPx = target.scrollHeight - target.scrollTop - target.clientHeight; + if (remainingScrollPx > 120) return; + runAsynchronouslyWithAlert(() => fetchTriggerPage({ offset: triggers.length, reset: false })); + }; + + const hasActiveFilters = ruleFilter !== ALL_FILTER_VALUE || actionFilter !== ALL_FILTER_VALUE || emailSearchInput !== "" || debouncedEmailSearch !== ""; + + return ( + +
+
+ setEmailSearchInput(event.target.value)} + placeholder="Search by email…" + leadingIcon={} + size="sm" + className="min-w-0 flex-1 sm:min-w-72" + /> + + +
+ {!isInitialLoading && triggers.length > 0 && ( +
+ + showing {triggers.length}{hasMore ? "+" : ""} + +
+ )} + + {loadingError == null ? null : ( + + )} + +
+ {isInitialLoading ? ( +
+ {["one", "two", "three", "four", "five"].map((skeletonId) => ( + + ))} +
+ ) : triggers.length === 0 ? ( + + ) : ( +
+ {triggers.map((trigger) => ( + + ))} +
+ )} + {isLoadingMore ? ( +
+ +
+ ) : null} +
+
+
+ ); +} + function RuleTriggerHistoryDialog({ ruleId, ruleDisplayName, @@ -1726,6 +2079,10 @@ function PageBody(props: PageBodyProps) { onChange={props.onDefaultActionChange} /> +
+ +
+
diff --git a/apps/dashboard/src/components/assistant-ui/tool-fallback.tsx b/apps/dashboard/src/components/assistant-ui/tool-fallback.tsx index f55ef236e..1ea7cbed2 100644 --- a/apps/dashboard/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/dashboard/src/components/assistant-ui/tool-fallback.tsx @@ -32,7 +32,11 @@ export function ToolFallback({ ? (status.error as { message?: string } | undefined)?.message : undefined; - const label = toolName === "queryAnalytics" ? "Analytics Query" : toolName; + const label = toolName === "queryAnalytics" + ? "Analytics Query" + : toolName === "readBranchConfig" + ? "Project Config" + : toolName; const queryArg = (args as { query?: string } | undefined)?.query ?? (argsText ? argsText : undefined); return ( diff --git a/apps/dashboard/src/components/commands/ask-ai.tsx b/apps/dashboard/src/components/commands/ask-ai.tsx index b2c62399b..2a0b525a5 100644 --- a/apps/dashboard/src/components/commands/ask-ai.tsx +++ b/apps/dashboard/src/components/commands/ask-ai.tsx @@ -41,7 +41,7 @@ const AIChatPreviewInner = memo(function AIChatPreview({ backendBaseUrl, currentUser: currentUser ?? undefined, systemPrompt: "command-center-ask-ai", - tools: ["docs", "sql-query"], + tools: ["docs", "sql-query", "read-config"], quality: "smart", speed: "slow", projectId, diff --git a/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx b/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx index 2e1d6b649..d81f20291 100644 --- a/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx +++ b/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx @@ -122,7 +122,7 @@ const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({ backendBaseUrl: browserBaseUrl, currentUser: () => currentUserRef.current, systemPrompt: "create-dashboard", - tools: ["update-dashboard"], + tools: projectIdRef.current ? ["update-dashboard", "read-config"] : ["update-dashboard"], quality: "smart", speed: "fast", projectId: projectIdRef.current, diff --git a/apps/dashboard/src/components/hexclave-companion/ai-chat-widget.tsx b/apps/dashboard/src/components/hexclave-companion/ai-chat-widget.tsx index 430115ad2..5586107cd 100644 --- a/apps/dashboard/src/components/hexclave-companion/ai-chat-widget.tsx +++ b/apps/dashboard/src/components/hexclave-companion/ai-chat-widget.tsx @@ -449,7 +449,7 @@ function AIChatWidgetInner({ backendBaseUrl, currentUser: currentUser ?? undefined, systemPrompt: "command-center-ask-ai", - tools: ["docs", "sql-query"], + tools: ["docs", "sql-query", "read-config"], quality: "smart", speed: "slow", projectId, diff --git a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts index 783a35a6e..dc4af39a8 100644 --- a/apps/dashboard/src/components/vibe-coding/chat-adapters.ts +++ b/apps/dashboard/src/components/vibe-coding/chat-adapters.ts @@ -142,7 +142,7 @@ export function createDashboardChatAdapter( onRunEnd?: () => void, ): ChatModelAdapter { const tools = projectId - ? ["update-dashboard", "sql-query"] + ? ["update-dashboard", "sql-query", "read-config"] : ["update-dashboard"]; return createUnifiedAiChatAdapter({ diff --git a/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts index 2ea964e4f..f7afa2a2f 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/ai-query.test.ts @@ -352,6 +352,7 @@ describeWithAi("AI Query Endpoint - Tools", () => { const validTools = [ "docs", "sql-query", + "read-config", "create-email-theme", "create-email-template", "create-email-draft", diff --git a/packages/shared/src/ai/unified-prompts/reminders.ts b/packages/shared/src/ai/unified-prompts/reminders.ts index 140960065..772faf872 100644 --- a/packages/shared/src/ai/unified-prompts/reminders.ts +++ b/packages/shared/src/ai/unified-prompts/reminders.ts @@ -29,5 +29,6 @@ export const remindersPrompt = deindent` - If available, always prefer editing the \`hexclave.config.ts\` file directly over asking the user to make changes on the dashboard. When implementing new features, you can always update the config file, and then tell the user about the changes you've made. The config file is automatically synced when using the local dashboard/dev environment with \`npx @hexclave/cli dev --config-file \`. - Hexclave's config files allow dot notation for nested properties. For example, the config \`{ auth: { allowSignUp: true }, "auth.password": { allowSignIn: true } }\` is the same as \`{ auth: { allowSignUp: true, password: { allowSignIn: true } } }\`. - You can use the \`npx @hexclave/cli exec \` command to run JavaScript with a pre-configured HexclaveServerApp available as \`hexclaveServerApp\`. This allows you to read and write from and to the Hexclave project as you would on the dashboard, but from the CLI. To read and write project configuration, see the note on the config file above. + - For advanced read queries, you can use \`hexclaveServerApp.queryAnalytics("")\`. Use \`SHOW TABLES\` and \`DESCRIBE TABLE\` to understand the schema of the available tables (columns have comments that may be useful as a description). - Hexclave was formerly known as Stack Auth. You may still see references to it as Stack Auth in some places. `;