@mantajs/cli 0.2.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/manta.mjs +20 -0
- package/bin/manta.ts +13 -0
- package/dist/admin/generate-admin-config.d.ts +8 -0
- package/dist/admin/generate-admin-config.d.ts.map +1 -0
- package/dist/admin/generate-admin-config.js +127 -0
- package/dist/admin/generate-admin-config.js.map +1 -0
- package/dist/ai/chat-handler.d.ts +10 -0
- package/dist/ai/chat-handler.d.ts.map +1 -0
- package/dist/ai/chat-handler.js +1353 -0
- package/dist/ai/chat-handler.js.map +1 -0
- package/dist/bootstrap/boot.d.ts +25 -0
- package/dist/bootstrap/boot.d.ts.map +1 -0
- package/dist/bootstrap/boot.js +344 -0
- package/dist/bootstrap/boot.js.map +1 -0
- package/dist/bootstrap/bootstrap-app.d.ts +71 -0
- package/dist/bootstrap/bootstrap-app.d.ts.map +1 -0
- package/dist/bootstrap/bootstrap-app.js +308 -0
- package/dist/bootstrap/bootstrap-app.js.map +1 -0
- package/dist/bootstrap/bootstrap-context.d.ts +76 -0
- package/dist/bootstrap/bootstrap-context.d.ts.map +1 -0
- package/dist/bootstrap/bootstrap-context.js +4 -0
- package/dist/bootstrap/bootstrap-context.js.map +1 -0
- package/dist/bootstrap/bootstrap-helpers.d.ts +71 -0
- package/dist/bootstrap/bootstrap-helpers.d.ts.map +1 -0
- package/dist/bootstrap/bootstrap-helpers.js +373 -0
- package/dist/bootstrap/bootstrap-helpers.js.map +1 -0
- package/dist/bootstrap/generate-types.d.ts +7 -0
- package/dist/bootstrap/generate-types.d.ts.map +1 -0
- package/dist/bootstrap/generate-types.js +832 -0
- package/dist/bootstrap/generate-types.js.map +1 -0
- package/dist/bootstrap/phases/assemble/index.d.ts +5 -0
- package/dist/bootstrap/phases/assemble/index.d.ts.map +1 -0
- package/dist/bootstrap/phases/assemble/index.js +5 -0
- package/dist/bootstrap/phases/assemble/index.js.map +1 -0
- package/dist/bootstrap/phases/assemble/load-links.d.ts +3 -0
- package/dist/bootstrap/phases/assemble/load-links.d.ts.map +1 -0
- package/dist/bootstrap/phases/assemble/load-links.js +160 -0
- package/dist/bootstrap/phases/assemble/load-links.js.map +1 -0
- package/dist/bootstrap/phases/assemble/load-modules.d.ts +3 -0
- package/dist/bootstrap/phases/assemble/load-modules.d.ts.map +1 -0
- package/dist/bootstrap/phases/assemble/load-modules.js +163 -0
- package/dist/bootstrap/phases/assemble/load-modules.js.map +1 -0
- package/dist/bootstrap/phases/assemble/load-resources.d.ts +3 -0
- package/dist/bootstrap/phases/assemble/load-resources.d.ts.map +1 -0
- package/dist/bootstrap/phases/assemble/load-resources.js +270 -0
- package/dist/bootstrap/phases/assemble/load-resources.js.map +1 -0
- package/dist/bootstrap/phases/assemble/wire-commands.d.ts +3 -0
- package/dist/bootstrap/phases/assemble/wire-commands.d.ts.map +1 -0
- package/dist/bootstrap/phases/assemble/wire-commands.js +408 -0
- package/dist/bootstrap/phases/assemble/wire-commands.js.map +1 -0
- package/dist/bootstrap/phases/assemble-modules.d.ts +3 -0
- package/dist/bootstrap/phases/assemble-modules.d.ts.map +1 -0
- package/dist/bootstrap/phases/assemble-modules.js +14 -0
- package/dist/bootstrap/phases/assemble-modules.js.map +1 -0
- package/dist/bootstrap/phases/build-app.d.ts +3 -0
- package/dist/bootstrap/phases/build-app.d.ts.map +1 -0
- package/dist/bootstrap/phases/build-app.js +15 -0
- package/dist/bootstrap/phases/build-app.js.map +1 -0
- package/dist/bootstrap/phases/discover-resources.d.ts +3 -0
- package/dist/bootstrap/phases/discover-resources.d.ts.map +1 -0
- package/dist/bootstrap/phases/discover-resources.js +60 -0
- package/dist/bootstrap/phases/discover-resources.js.map +1 -0
- package/dist/bootstrap/phases/index.d.ts +7 -0
- package/dist/bootstrap/phases/index.d.ts.map +1 -0
- package/dist/bootstrap/phases/index.js +7 -0
- package/dist/bootstrap/phases/index.js.map +1 -0
- package/dist/bootstrap/phases/init-infra.d.ts +3 -0
- package/dist/bootstrap/phases/init-infra.d.ts.map +1 -0
- package/dist/bootstrap/phases/init-infra.js +193 -0
- package/dist/bootstrap/phases/init-infra.js.map +1 -0
- package/dist/bootstrap/phases/seed-dev-users.d.ts +3 -0
- package/dist/bootstrap/phases/seed-dev-users.d.ts.map +1 -0
- package/dist/bootstrap/phases/seed-dev-users.js +93 -0
- package/dist/bootstrap/phases/seed-dev-users.js.map +1 -0
- package/dist/bootstrap/phases/wire/auth-helpers.d.ts +12 -0
- package/dist/bootstrap/phases/wire/auth-helpers.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/auth-helpers.js +25 -0
- package/dist/bootstrap/phases/wire/auth-helpers.js.map +1 -0
- package/dist/bootstrap/phases/wire/contexts/context-registry.d.ts +4 -0
- package/dist/bootstrap/phases/wire/contexts/context-registry.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/contexts/context-registry.js +96 -0
- package/dist/bootstrap/phases/wire/contexts/context-registry.js.map +1 -0
- package/dist/bootstrap/phases/wire/contexts/cqrs-routes.d.ts +3 -0
- package/dist/bootstrap/phases/wire/contexts/cqrs-routes.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/contexts/cqrs-routes.js +138 -0
- package/dist/bootstrap/phases/wire/contexts/cqrs-routes.js.map +1 -0
- package/dist/bootstrap/phases/wire/contexts/index.d.ts +6 -0
- package/dist/bootstrap/phases/wire/contexts/index.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/contexts/index.js +6 -0
- package/dist/bootstrap/phases/wire/contexts/index.js.map +1 -0
- package/dist/bootstrap/phases/wire/contexts/query-endpoints.d.ts +3 -0
- package/dist/bootstrap/phases/wire/contexts/query-endpoints.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/contexts/query-endpoints.js +116 -0
- package/dist/bootstrap/phases/wire/contexts/query-endpoints.js.map +1 -0
- package/dist/bootstrap/phases/wire/contexts/spa-warnings.d.ts +3 -0
- package/dist/bootstrap/phases/wire/contexts/spa-warnings.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/contexts/spa-warnings.js +17 -0
- package/dist/bootstrap/phases/wire/contexts/spa-warnings.js.map +1 -0
- package/dist/bootstrap/phases/wire/contexts/user-routes.d.ts +3 -0
- package/dist/bootstrap/phases/wire/contexts/user-routes.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/contexts/user-routes.js +83 -0
- package/dist/bootstrap/phases/wire/contexts/user-routes.js.map +1 -0
- package/dist/bootstrap/phases/wire/index.d.ts +5 -0
- package/dist/bootstrap/phases/wire/index.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/index.js +5 -0
- package/dist/bootstrap/phases/wire/index.js.map +1 -0
- package/dist/bootstrap/phases/wire/wire-adapter.d.ts +12 -0
- package/dist/bootstrap/phases/wire/wire-adapter.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/wire-adapter.js +156 -0
- package/dist/bootstrap/phases/wire/wire-adapter.js.map +1 -0
- package/dist/bootstrap/phases/wire/wire-auth.d.ts +3 -0
- package/dist/bootstrap/phases/wire/wire-auth.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/wire-auth.js +46 -0
- package/dist/bootstrap/phases/wire/wire-auth.js.map +1 -0
- package/dist/bootstrap/phases/wire/wire-contexts.d.ts +3 -0
- package/dist/bootstrap/phases/wire/wire-contexts.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/wire-contexts.js +15 -0
- package/dist/bootstrap/phases/wire/wire-contexts.js.map +1 -0
- package/dist/bootstrap/phases/wire/wire-cron-routes.d.ts +3 -0
- package/dist/bootstrap/phases/wire/wire-cron-routes.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/wire-cron-routes.js +102 -0
- package/dist/bootstrap/phases/wire/wire-cron-routes.js.map +1 -0
- package/dist/bootstrap/phases/wire/wire-extras.d.ts +3 -0
- package/dist/bootstrap/phases/wire/wire-extras.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/wire-extras.js +305 -0
- package/dist/bootstrap/phases/wire/wire-extras.js.map +1 -0
- package/dist/bootstrap/phases/wire/wire-workflow-routes.d.ts +3 -0
- package/dist/bootstrap/phases/wire/wire-workflow-routes.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire/wire-workflow-routes.js +212 -0
- package/dist/bootstrap/phases/wire/wire-workflow-routes.js.map +1 -0
- package/dist/bootstrap/phases/wire-http.d.ts +3 -0
- package/dist/bootstrap/phases/wire-http.d.ts.map +1 -0
- package/dist/bootstrap/phases/wire-http.js +10 -0
- package/dist/bootstrap/phases/wire-http.js.map +1 -0
- package/dist/bootstrap/validate-generated-ts.d.ts +6 -0
- package/dist/bootstrap/validate-generated-ts.d.ts.map +1 -0
- package/dist/bootstrap/validate-generated-ts.js +26 -0
- package/dist/bootstrap/validate-generated-ts.js.map +1 -0
- package/dist/build/generate-manifest.d.ts +10 -0
- package/dist/build/generate-manifest.d.ts.map +1 -0
- package/dist/build/generate-manifest.js +138 -0
- package/dist/build/generate-manifest.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +421 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/build.d.ts +21 -0
- package/dist/commands/build.d.ts.map +1 -0
- package/dist/commands/build.js +399 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/db/create.d.ts +17 -0
- package/dist/commands/db/create.d.ts.map +1 -0
- package/dist/commands/db/create.js +94 -0
- package/dist/commands/db/create.js.map +1 -0
- package/dist/commands/db/diff.d.ts +39 -0
- package/dist/commands/db/diff.d.ts.map +1 -0
- package/dist/commands/db/diff.js +81 -0
- package/dist/commands/db/diff.js.map +1 -0
- package/dist/commands/db/generate.d.ts +58 -0
- package/dist/commands/db/generate.d.ts.map +1 -0
- package/dist/commands/db/generate.js +138 -0
- package/dist/commands/db/generate.js.map +1 -0
- package/dist/commands/db/migrate.d.ts +29 -0
- package/dist/commands/db/migrate.d.ts.map +1 -0
- package/dist/commands/db/migrate.js +118 -0
- package/dist/commands/db/migrate.js.map +1 -0
- package/dist/commands/db/pg-deps.d.ts +30 -0
- package/dist/commands/db/pg-deps.d.ts.map +1 -0
- package/dist/commands/db/pg-deps.js +178 -0
- package/dist/commands/db/pg-deps.js.map +1 -0
- package/dist/commands/db/rollback.d.ts +21 -0
- package/dist/commands/db/rollback.d.ts.map +1 -0
- package/dist/commands/db/rollback.js +85 -0
- package/dist/commands/db/rollback.js.map +1 -0
- package/dist/commands/db/types.d.ts +113 -0
- package/dist/commands/db/types.d.ts.map +1 -0
- package/dist/commands/db/types.js +4 -0
- package/dist/commands/db/types.js.map +1 -0
- package/dist/commands/dev.d.ts +12 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +79 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/exec.d.ts +21 -0
- package/dist/commands/exec.d.ts.map +1 -0
- package/dist/commands/exec.js +148 -0
- package/dist/commands/exec.js.map +1 -0
- package/dist/commands/generate.d.ts +11 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/generate.js +19 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/init.d.ts +14 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +476 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/start.d.ts +15 -0
- package/dist/commands/start.d.ts.map +1 -0
- package/dist/commands/start.js +121 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/user.d.ts +19 -0
- package/dist/commands/user.d.ts.map +1 -0
- package/dist/commands/user.js +125 -0
- package/dist/commands/user.js.map +1 -0
- package/dist/config/load-config.d.ts +23 -0
- package/dist/config/load-config.d.ts.map +1 -0
- package/dist/config/load-config.js +105 -0
- package/dist/config/load-config.js.map +1 -0
- package/dist/config/load-env.d.ts +11 -0
- package/dist/config/load-env.d.ts.map +1 -0
- package/dist/config/load-env.js +61 -0
- package/dist/config/load-env.js.map +1 -0
- package/dist/config/resolve-adapters.d.ts +23 -0
- package/dist/config/resolve-adapters.d.ts.map +1 -0
- package/dist/config/resolve-adapters.js +96 -0
- package/dist/config/resolve-adapters.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/jiti.d.ts +2 -0
- package/dist/jiti.d.ts.map +1 -0
- package/dist/jiti.js +2 -0
- package/dist/jiti.js.map +1 -0
- package/dist/openapi/generate-spec.d.ts +56 -0
- package/dist/openapi/generate-spec.d.ts.map +1 -0
- package/dist/openapi/generate-spec.js +491 -0
- package/dist/openapi/generate-spec.js.map +1 -0
- package/dist/openapi/index.d.ts +4 -0
- package/dist/openapi/index.d.ts.map +1 -0
- package/dist/openapi/index.js +3 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/openapi/swagger-html.d.ts +2 -0
- package/dist/openapi/swagger-html.d.ts.map +1 -0
- package/dist/openapi/swagger-html.js +29 -0
- package/dist/openapi/swagger-html.js.map +1 -0
- package/dist/plugins/merge-resources.d.ts +18 -0
- package/dist/plugins/merge-resources.d.ts.map +1 -0
- package/dist/plugins/merge-resources.js +76 -0
- package/dist/plugins/merge-resources.js.map +1 -0
- package/dist/plugins/resolve-plugins.d.ts +14 -0
- package/dist/plugins/resolve-plugins.d.ts.map +1 -0
- package/dist/plugins/resolve-plugins.js +73 -0
- package/dist/plugins/resolve-plugins.js.map +1 -0
- package/dist/resource-loader.d.ts +151 -0
- package/dist/resource-loader.d.ts.map +1 -0
- package/dist/resource-loader.js +456 -0
- package/dist/resource-loader.js.map +1 -0
- package/dist/route-discovery.d.ts +33 -0
- package/dist/route-discovery.d.ts.map +1 -0
- package/dist/route-discovery.js +69 -0
- package/dist/route-discovery.js.map +1 -0
- package/dist/server-bootstrap.d.ts +38 -0
- package/dist/server-bootstrap.d.ts.map +1 -0
- package/dist/server-bootstrap.js +21 -0
- package/dist/server-bootstrap.js.map +1 -0
- package/dist/spa/generate-spa.d.ts +15 -0
- package/dist/spa/generate-spa.d.ts.map +1 -0
- package/dist/spa/generate-spa.js +357 -0
- package/dist/spa/generate-spa.js.map +1 -0
- package/dist/templates/agent/nextjs.md +129 -0
- package/dist/templates/agent/nuxt.md +98 -0
- package/dist/templates/agent/standalone.md +498 -0
- package/dist/types.d.ts +145 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/colors.d.ts +7 -0
- package/dist/utils/colors.d.ts.map +1 -0
- package/dist/utils/colors.js +8 -0
- package/dist/utils/colors.js.map +1 -0
- package/dist/utils/logger.d.ts +10 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +27 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/prompts.d.ts +6 -0
- package/dist/utils/prompts.d.ts.map +1 -0
- package/dist/utils/prompts.js +28 -0
- package/dist/utils/prompts.js.map +1 -0
- package/dist/utils/spinner.d.ts +7 -0
- package/dist/utils/spinner.d.ts.map +1 -0
- package/dist/utils/spinner.js +20 -0
- package/dist/utils/spinner.js.map +1 -0
- package/package.json +80 -0
|
@@ -0,0 +1,1353 @@
|
|
|
1
|
+
// @ts-nocheck — AI SDK tools are dynamically loaded via require('ai'), types are unresolvable at compile time
|
|
2
|
+
// AI Chat handler — registered as POST /admin/ai/chat by the bootstrap.
|
|
3
|
+
// Uses Vercel AI SDK with multi-provider support.
|
|
4
|
+
// Tools: CQRS commands + data queries + dashboard modifications.
|
|
5
|
+
import { getRequestBody } from '../server-bootstrap';
|
|
6
|
+
let _warehouseCache = null;
|
|
7
|
+
const WAREHOUSE_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
8
|
+
async function getWarehouseIndexSection() {
|
|
9
|
+
if (!process.env.POSTHOG_API_KEY)
|
|
10
|
+
return null;
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
if (_warehouseCache && _warehouseCache.expiresAt > now) {
|
|
13
|
+
return _warehouseCache.promptSection;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const host = process.env.POSTHOG_HOST ?? 'https://eu.i.posthog.com';
|
|
17
|
+
const key = process.env.POSTHOG_API_KEY;
|
|
18
|
+
const res = await fetch(`${host}/api/projects/@current/warehouse_tables/`, {
|
|
19
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok)
|
|
22
|
+
return null;
|
|
23
|
+
const data = (await res.json());
|
|
24
|
+
const tables = data.results ?? [];
|
|
25
|
+
if (tables.length === 0)
|
|
26
|
+
return null;
|
|
27
|
+
// Group by source type (klaviyo, shopify, stripe, manual, …) for readability
|
|
28
|
+
const bySource = new Map();
|
|
29
|
+
for (const t of tables) {
|
|
30
|
+
const rawName = t.name;
|
|
31
|
+
// REST returns 'klaviyo_events', HogQL also accepts 'klaviyo.events' — prefer the dotted
|
|
32
|
+
// form since it's what PostHog UI displays in its sidebar and matches the schema.table
|
|
33
|
+
// mental model.
|
|
34
|
+
const firstUnderscore = rawName.indexOf('_');
|
|
35
|
+
const dotted = firstUnderscore > 0 ? `${rawName.slice(0, firstUnderscore)}.${rawName.slice(firstUnderscore + 1)}` : rawName;
|
|
36
|
+
const source = t.source?.source_type ?? 'manual';
|
|
37
|
+
const list = bySource.get(source) ?? [];
|
|
38
|
+
list.push({ dotted, raw: rawName, columns: t.columns ?? [] });
|
|
39
|
+
bySource.set(source, list);
|
|
40
|
+
}
|
|
41
|
+
const lines = [];
|
|
42
|
+
lines.push('## Available PostHog Data Warehouse tables (auto-discovered)');
|
|
43
|
+
lines.push('');
|
|
44
|
+
lines.push(`The following tables are synced in this project's PostHog warehouse. Query them with`);
|
|
45
|
+
lines.push('`query_posthog_hogql` using either the dotted form (`klaviyo.events`) or the underscore');
|
|
46
|
+
lines.push('form (`klaviyo_events`) — HogQL accepts both. **Column names and types are listed below,');
|
|
47
|
+
lines.push('so you do NOT need to sample rows with `SELECT * LIMIT 1` to discover columns for these');
|
|
48
|
+
lines.push('tables.** Just write your analytics query directly using the listed columns.');
|
|
49
|
+
lines.push('');
|
|
50
|
+
for (const [source, list] of bySource) {
|
|
51
|
+
lines.push(`### Source: ${source}`);
|
|
52
|
+
lines.push('');
|
|
53
|
+
for (const t of list) {
|
|
54
|
+
const colsStr = t.columns.map((c) => `${c.name}:${c.type}`).join(', ');
|
|
55
|
+
lines.push(`- \`${t.dotted}\` (aka \`${t.raw}\`, ${t.columns.length} cols) — ${colsStr}`);
|
|
56
|
+
}
|
|
57
|
+
lines.push('');
|
|
58
|
+
}
|
|
59
|
+
lines.push('**Cross-source joins** are supported in HogQL. Example: correlate Klaviyo clicks with');
|
|
60
|
+
lines.push('PostHog checkout events by joining on email or distinct_id.');
|
|
61
|
+
const section = lines.join('\n');
|
|
62
|
+
_warehouseCache = {
|
|
63
|
+
promptSection: section,
|
|
64
|
+
expiresAt: now + WAREHOUSE_CACHE_TTL_MS,
|
|
65
|
+
};
|
|
66
|
+
return section;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const PROVIDER_DEFAULTS = {
|
|
73
|
+
anthropic: { envKey: 'ANTHROPIC_API_KEY', model: 'claude-sonnet-4-20250514' },
|
|
74
|
+
openai: { envKey: 'OPENAI_API_KEY', model: 'gpt-4o' },
|
|
75
|
+
google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', model: 'gemini-2.0-flash' },
|
|
76
|
+
mistral: { envKey: 'MISTRAL_API_KEY', model: 'mistral-large-latest' },
|
|
77
|
+
};
|
|
78
|
+
async function getModel() {
|
|
79
|
+
const providerName = (process.env.MANTA_AI_PROVIDER || 'anthropic');
|
|
80
|
+
const config = PROVIDER_DEFAULTS[providerName];
|
|
81
|
+
if (!config)
|
|
82
|
+
throw new Error(`Unknown AI provider: ${providerName}. Use: anthropic, openai, google, mistral`);
|
|
83
|
+
const apiKey = process.env[config.envKey];
|
|
84
|
+
if (!apiKey)
|
|
85
|
+
throw new Error(`${config.envKey} not configured`);
|
|
86
|
+
const modelId = process.env.MANTA_AI_MODEL || config.model;
|
|
87
|
+
switch (providerName) {
|
|
88
|
+
case 'anthropic': {
|
|
89
|
+
const { createAnthropic } = await import('@ai-sdk/anthropic');
|
|
90
|
+
return createAnthropic({ apiKey })(modelId);
|
|
91
|
+
}
|
|
92
|
+
case 'openai': {
|
|
93
|
+
const { createOpenAI } = await import('@ai-sdk/openai');
|
|
94
|
+
return createOpenAI({ apiKey })(modelId);
|
|
95
|
+
}
|
|
96
|
+
case 'google': {
|
|
97
|
+
const { createGoogleGenerativeAI } = await import('@ai-sdk/google');
|
|
98
|
+
return createGoogleGenerativeAI({ apiKey })(modelId);
|
|
99
|
+
}
|
|
100
|
+
case 'mistral': {
|
|
101
|
+
const { createMistral } = await import('@ai-sdk/mistral');
|
|
102
|
+
return createMistral({ apiKey })(modelId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Extract a structured schema from a DmlEntity instance.
|
|
108
|
+
* Parses each property via .parse() to get type, nullable, enum values, etc.
|
|
109
|
+
*/
|
|
110
|
+
function extractEntitySchema(entity, compensableMethods = [], links = []) {
|
|
111
|
+
const fields = [];
|
|
112
|
+
const relations = [];
|
|
113
|
+
for (const [name, value] of Object.entries(entity.schema)) {
|
|
114
|
+
const v = value;
|
|
115
|
+
// Relation — has __dmlRelation: true
|
|
116
|
+
if (v?.__dmlRelation === true) {
|
|
117
|
+
let targetName = '?';
|
|
118
|
+
try {
|
|
119
|
+
const target = v.target?.();
|
|
120
|
+
targetName = target?.name ?? '?';
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* lazy ref may fail */
|
|
124
|
+
}
|
|
125
|
+
relations.push({ name, type: v.type, target: targetName });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// Property — has .parse() method (BaseProperty, NullableModifier, PrimaryKeyModifier)
|
|
129
|
+
if (typeof v?.parse === 'function') {
|
|
130
|
+
try {
|
|
131
|
+
const meta = v.parse(name);
|
|
132
|
+
const field = { name, type: meta.dataType?.name ?? 'unknown' };
|
|
133
|
+
if (meta.nullable)
|
|
134
|
+
field.nullable = true;
|
|
135
|
+
if (meta.primaryKey)
|
|
136
|
+
field.primaryKey = true;
|
|
137
|
+
if (meta.values)
|
|
138
|
+
field.values = meta.values;
|
|
139
|
+
if (meta.defaultValue !== undefined)
|
|
140
|
+
field.default = meta.defaultValue;
|
|
141
|
+
fields.push(field);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
fields.push({ name, type: 'unknown' });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { name: entity.name, fields, relations, links, commands: compensableMethods };
|
|
149
|
+
}
|
|
150
|
+
// ── Tools ────────────────────────────────────────────────────────
|
|
151
|
+
function buildTools(app, moduleNames, linkGraph, navigationOverride, defaultNavigation) {
|
|
152
|
+
// Lazy import — AI SDK is optional
|
|
153
|
+
const z = require('zod');
|
|
154
|
+
const { tool } = require('ai');
|
|
155
|
+
// Anthropic (and most providers) require tool names to match ^[a-zA-Z0-9_-]{1,64}$.
|
|
156
|
+
// Command names can legitimately contain ':' (module-scoped like `posthog:track-event`)
|
|
157
|
+
// or '.' (entity commands like `catalog.create-product`). Normalize any non-alphanumeric,
|
|
158
|
+
// non-underscore character to '_'. Without this, streamText emits a silent error frame
|
|
159
|
+
// ("3:An error occurred.") and the AI panel appears broken.
|
|
160
|
+
const sanitizeToolName = (name) => name.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
161
|
+
// Discover available commands from registry (explicit defineCommand files)
|
|
162
|
+
const commandTools = {};
|
|
163
|
+
try {
|
|
164
|
+
const registry = app.resolve('commandRegistry');
|
|
165
|
+
for (const entry of registry.list()) {
|
|
166
|
+
const cmdName = `command_${sanitizeToolName(entry.name)}`;
|
|
167
|
+
commandTools[cmdName] = tool({
|
|
168
|
+
description: entry.description,
|
|
169
|
+
parameters: entry.inputSchema,
|
|
170
|
+
execute: async (input) => {
|
|
171
|
+
const callable = app.commands[entry.name];
|
|
172
|
+
if (!callable)
|
|
173
|
+
return { error: `Command "${entry.name}" not found` };
|
|
174
|
+
try {
|
|
175
|
+
const result = await callable(input);
|
|
176
|
+
return { success: true, data: result };
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
return { error: err.message };
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* no command registry */
|
|
187
|
+
}
|
|
188
|
+
// Also expose auto-generated entity commands (CRUD + link/unlink)
|
|
189
|
+
try {
|
|
190
|
+
const entityCmdRegistry = app.resolve('__entityCommandRegistry');
|
|
191
|
+
for (const [cmdName, entityCmd] of entityCmdRegistry.entries()) {
|
|
192
|
+
const toolName = `command_${sanitizeToolName(cmdName)}`;
|
|
193
|
+
if (commandTools[toolName])
|
|
194
|
+
continue; // explicit command already registered (override)
|
|
195
|
+
commandTools[toolName] = tool({
|
|
196
|
+
description: entityCmd.description,
|
|
197
|
+
parameters: entityCmd.input,
|
|
198
|
+
execute: async (input) => {
|
|
199
|
+
const callable = app.commands[cmdName];
|
|
200
|
+
if (!callable)
|
|
201
|
+
return { error: `Command "${cmdName}" not found` };
|
|
202
|
+
try {
|
|
203
|
+
const result = await callable(input);
|
|
204
|
+
return { success: true, data: result };
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
return { error: err.message };
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
/* no entity command registry */
|
|
215
|
+
}
|
|
216
|
+
// Discover available queries from registry (defineQuery files — CQRS reads)
|
|
217
|
+
const queryTools = {};
|
|
218
|
+
try {
|
|
219
|
+
const queryRegistry = app.resolve('queryRegistry');
|
|
220
|
+
for (const entry of queryRegistry.list()) {
|
|
221
|
+
const toolName = `query_${entry.name.replace(/[-:]/g, '_')}`;
|
|
222
|
+
queryTools[toolName] = tool({
|
|
223
|
+
description: entry.description,
|
|
224
|
+
parameters: entry.input,
|
|
225
|
+
execute: async (input) => {
|
|
226
|
+
try {
|
|
227
|
+
const result = await entry.handler(input, {
|
|
228
|
+
query: app.resolve('queryService'),
|
|
229
|
+
log: app.logger ?? console,
|
|
230
|
+
auth: null,
|
|
231
|
+
headers: {},
|
|
232
|
+
});
|
|
233
|
+
return { success: true, data: result };
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
return { error: err.message };
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
/* no query registry */
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
// ── Data tools (CQRS query) ──────────────────────────────────
|
|
247
|
+
query_entity: tool({
|
|
248
|
+
description: `Query entities. Returns { data: [...], count: number }.
|
|
249
|
+
|
|
250
|
+
Entities (camelCase): ${moduleNames.join(', ')}
|
|
251
|
+
|
|
252
|
+
To include relations, add the relation name to fields:
|
|
253
|
+
query_entity({ entity: "customerGroup", fields: ["name", "customers"] })
|
|
254
|
+
→ Each result includes a "customers" array with linked entities.
|
|
255
|
+
Count the array length to get the count.
|
|
256
|
+
|
|
257
|
+
Relations: ${linkGraph
|
|
258
|
+
.map((l) => {
|
|
259
|
+
const isMany = l.cardinality === 'M:N';
|
|
260
|
+
return `${l.left} → ${isMany ? `${l.right}s` : l.right}, ${l.right} → ${isMany ? `${l.left}s` : l.left}`;
|
|
261
|
+
})
|
|
262
|
+
.join('; ')}`,
|
|
263
|
+
parameters: z.object({
|
|
264
|
+
entity: z.string().describe(`Entity name (camelCase). Available: ${moduleNames.join(', ')}`),
|
|
265
|
+
fields: z
|
|
266
|
+
.array(z.string())
|
|
267
|
+
.optional()
|
|
268
|
+
.describe('Fields to return. Add relation names to include related entities (e.g. ["name", "customers"]). Omit for all fields.'),
|
|
269
|
+
filters: z
|
|
270
|
+
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]))
|
|
271
|
+
.optional()
|
|
272
|
+
.describe('Filter conditions. Exact match: { status: "active" }. Multiple values (IN): { status: ["active", "archived"] }. NO operators like $ne, $gt, $in — only plain values or arrays.'),
|
|
273
|
+
limit: z.number().optional().describe('Max results (default 20)'),
|
|
274
|
+
offset: z.number().optional().describe('Pagination offset'),
|
|
275
|
+
sort: z.string().optional().describe('Sort field, prefix with - for desc (e.g. "-created_at")'),
|
|
276
|
+
}),
|
|
277
|
+
execute: async ({ entity, fields, filters, limit, offset, sort }) => {
|
|
278
|
+
try {
|
|
279
|
+
const queryService = app.resolve('queryService');
|
|
280
|
+
if (queryService && typeof queryService.graphAndCount === 'function') {
|
|
281
|
+
const sortObj = sort
|
|
282
|
+
? { [sort.startsWith('-') ? sort.slice(1) : sort]: sort.startsWith('-') ? 'desc' : 'asc' }
|
|
283
|
+
: undefined;
|
|
284
|
+
const [data, count] = await queryService.graphAndCount({
|
|
285
|
+
entity,
|
|
286
|
+
fields,
|
|
287
|
+
filters,
|
|
288
|
+
sort: sortObj,
|
|
289
|
+
pagination: { limit: limit ?? 20, offset: offset ?? 0 },
|
|
290
|
+
});
|
|
291
|
+
return { data, count };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (queryErr) {
|
|
295
|
+
// Log the error — don't silently swallow it
|
|
296
|
+
console.error(`[AI query_entity] graphAndCount failed for "${entity}":`, queryErr.message);
|
|
297
|
+
// Fall back to service.list()
|
|
298
|
+
}
|
|
299
|
+
// Fallback: direct service.list() for modules without Query Graph
|
|
300
|
+
let service = null;
|
|
301
|
+
try {
|
|
302
|
+
service = app.resolve(`${entity}ModuleService`);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
try {
|
|
306
|
+
service = app.modules[entity] ?? null;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
/* not found */
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (!service || typeof service.list !== 'function') {
|
|
313
|
+
return { error: `Entity "${entity}" not found. Available: ${moduleNames.join(', ')}` };
|
|
314
|
+
}
|
|
315
|
+
let data = (await service.list());
|
|
316
|
+
// Filters
|
|
317
|
+
if (filters) {
|
|
318
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
319
|
+
if (Array.isArray(value)) {
|
|
320
|
+
data = data.filter((item) => value.includes(String(item[key])));
|
|
321
|
+
}
|
|
322
|
+
else if (value !== undefined && value !== null) {
|
|
323
|
+
data = data.filter((item) => String(item[key]) === String(value));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Sort
|
|
328
|
+
if (sort) {
|
|
329
|
+
const desc = sort.startsWith('-');
|
|
330
|
+
const field = desc ? sort.slice(1) : sort;
|
|
331
|
+
data.sort((a, b) => {
|
|
332
|
+
const va = a[field] ?? '';
|
|
333
|
+
const vb = b[field] ?? '';
|
|
334
|
+
if (typeof va === 'number' && typeof vb === 'number')
|
|
335
|
+
return desc ? vb - va : va - vb;
|
|
336
|
+
return desc ? String(vb).localeCompare(String(va)) : String(va).localeCompare(String(vb));
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const count = data.length;
|
|
340
|
+
const sliced = data.slice(offset ?? 0, (offset ?? 0) + (limit ?? 20));
|
|
341
|
+
return { data: sliced, count };
|
|
342
|
+
},
|
|
343
|
+
}),
|
|
344
|
+
get_entity: tool({
|
|
345
|
+
description: 'Get a single entity by ID.',
|
|
346
|
+
parameters: z.object({
|
|
347
|
+
entity: z.string().describe('Entity/module name'),
|
|
348
|
+
id: z.string().describe('Entity ID'),
|
|
349
|
+
}),
|
|
350
|
+
execute: async ({ entity, id }) => {
|
|
351
|
+
let service = null;
|
|
352
|
+
try {
|
|
353
|
+
service = app.resolve(`${entity}ModuleService`);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
try {
|
|
357
|
+
service = app.modules[entity] ?? null;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
/* not found */
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (!service || typeof service.findById !== 'function') {
|
|
364
|
+
return { error: `Entity "${entity}" does not support findById` };
|
|
365
|
+
}
|
|
366
|
+
const item = await service.findById(id);
|
|
367
|
+
if (!item)
|
|
368
|
+
return { error: `${entity} "${id}" not found` };
|
|
369
|
+
return item;
|
|
370
|
+
},
|
|
371
|
+
}),
|
|
372
|
+
list_entities: tool({
|
|
373
|
+
description: 'List all available entity types with their current count.',
|
|
374
|
+
parameters: z.object({}),
|
|
375
|
+
execute: async () => {
|
|
376
|
+
const results = {};
|
|
377
|
+
for (const name of moduleNames) {
|
|
378
|
+
const service = app.modules[name];
|
|
379
|
+
if (service && typeof service.list === 'function') {
|
|
380
|
+
try {
|
|
381
|
+
const items = await service.list();
|
|
382
|
+
results[name] = Array.isArray(items) ? items.length : 0;
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
results[name] = -1;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return results;
|
|
390
|
+
},
|
|
391
|
+
}),
|
|
392
|
+
describe_entity: tool({
|
|
393
|
+
description: 'Get the full schema of an entity: field names, types, enum values, cross-module links, and available commands (mutations). Call this BEFORE querying to know what fields, relations, and filters are available. Links tell you which related entities you can include via dotted field paths in query_entity.',
|
|
394
|
+
parameters: z.object({
|
|
395
|
+
entity: z.string().describe(`Entity name (camelCase). Available: ${moduleNames.join(', ')}`),
|
|
396
|
+
}),
|
|
397
|
+
execute: async ({ entity }) => {
|
|
398
|
+
let service = null;
|
|
399
|
+
try {
|
|
400
|
+
service = app.resolve(`${entity}Service`);
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
try {
|
|
404
|
+
service = app.resolve(`${entity}ModuleService`);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
try {
|
|
408
|
+
service = app.modules[entity] ?? null;
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
/* not found */
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// External entities (defineModel(...).external()) have no service — they live only
|
|
416
|
+
// in the entity registry and are resolved via extendQueryGraph resolvers. Fall back
|
|
417
|
+
// to the registry here so the AI can still discover their schema for query_entity.
|
|
418
|
+
if (!service) {
|
|
419
|
+
try {
|
|
420
|
+
const entityRegistry = app.resolve('__entityRegistry');
|
|
421
|
+
const canonical = [...entityRegistry.keys()].find((k) => k.toLowerCase() === entity.toLowerCase());
|
|
422
|
+
const dml = canonical ? entityRegistry.get(canonical) : undefined;
|
|
423
|
+
if (dml?.schema) {
|
|
424
|
+
return extractEntitySchema(dml, [], []);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
/* entity registry not available */
|
|
429
|
+
}
|
|
430
|
+
return { error: `Entity "${entity}" not found. Available: ${moduleNames.join(', ')}` };
|
|
431
|
+
}
|
|
432
|
+
// Resolve cross-module links for this entity
|
|
433
|
+
const entityLinks = [];
|
|
434
|
+
try {
|
|
435
|
+
const { getRegisteredLinks } = await import('@mantajs/core');
|
|
436
|
+
for (const link of getRegisteredLinks()) {
|
|
437
|
+
if (link.leftModule === entity || link.leftEntity.toLowerCase() === entity) {
|
|
438
|
+
entityLinks.push({
|
|
439
|
+
linkedEntity: link.rightEntity,
|
|
440
|
+
linkedModule: link.rightModule,
|
|
441
|
+
description: `Use "${link.rightModule}.fieldName" in fields to include ${link.rightEntity} data`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
if (link.rightModule === entity || link.rightEntity.toLowerCase() === entity) {
|
|
445
|
+
entityLinks.push({
|
|
446
|
+
linkedEntity: link.leftEntity,
|
|
447
|
+
linkedModule: link.leftModule,
|
|
448
|
+
description: `Use "${link.leftModule}.fieldName" in fields to include ${link.leftEntity} data`,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
/* no links */
|
|
455
|
+
}
|
|
456
|
+
// Extract DML schema from __entity (service.define) or $modelObjects (createService)
|
|
457
|
+
const dmlEntity = service.__entity;
|
|
458
|
+
if (dmlEntity?.schema) {
|
|
459
|
+
const compensableMethods = service.__compensableMethods ?? [];
|
|
460
|
+
return extractEntitySchema(dmlEntity, compensableMethods, entityLinks);
|
|
461
|
+
}
|
|
462
|
+
// Fallback for createService-based services — sample first item to infer fields
|
|
463
|
+
if (typeof service.list === 'function') {
|
|
464
|
+
try {
|
|
465
|
+
const items = (await service.list());
|
|
466
|
+
if (items.length > 0) {
|
|
467
|
+
const sample = items[0];
|
|
468
|
+
const fields = Object.keys(sample).map((k) => ({
|
|
469
|
+
name: k,
|
|
470
|
+
type: typeof sample[k] === 'number' ? 'number' : typeof sample[k] === 'boolean' ? 'boolean' : 'text',
|
|
471
|
+
}));
|
|
472
|
+
return { name: entity, fields, relations: [], links: entityLinks, commands: [] };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
/* empty */
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return { name: entity, fields: [], relations: [], links: entityLinks, commands: [] };
|
|
480
|
+
},
|
|
481
|
+
}),
|
|
482
|
+
// ── Dashboard tools ──────────────────────────────────────────
|
|
483
|
+
render_component: tool({
|
|
484
|
+
description: `Render a visual component in the chat.
|
|
485
|
+
|
|
486
|
+
DataTable — for lists (max 5 rows per page, with search and pagination):
|
|
487
|
+
component: { type: "DataTable", props: { columns: [{ key: "name", label: "Name" }, { key: "count", label: "Count" }] } }
|
|
488
|
+
data: { items: [...], count: N }
|
|
489
|
+
Arrays in data are auto-displayed as counts.
|
|
490
|
+
|
|
491
|
+
InfoCard — for single entity details:
|
|
492
|
+
component: { type: "InfoCard", props: { title: "Customer", fields: [{ key: "email", label: "Email" }] } }
|
|
493
|
+
data: { email: "john@example.com", ... }
|
|
494
|
+
|
|
495
|
+
StatsCard — for metrics:
|
|
496
|
+
component: { type: "StatsCard", props: { title: "Overview", metrics: [{ label: "Total", key: "total" }] } }
|
|
497
|
+
data: { total: 42 }`,
|
|
498
|
+
parameters: z.object({
|
|
499
|
+
component: z.object({
|
|
500
|
+
type: z.enum(['DataTable', 'InfoCard', 'StatsCard']),
|
|
501
|
+
props: z.record(z.string(), z.unknown()),
|
|
502
|
+
}),
|
|
503
|
+
data: z.record(z.string(), z.unknown()).optional(),
|
|
504
|
+
title: z.string().optional(),
|
|
505
|
+
}),
|
|
506
|
+
execute: async ({ component, data, title }) => {
|
|
507
|
+
return { __renderComponent: true, component, data: data || {}, title };
|
|
508
|
+
},
|
|
509
|
+
}),
|
|
510
|
+
modify_component: tool({
|
|
511
|
+
description: 'Override a data component on the current page. Provide the COMPLETE replacement.',
|
|
512
|
+
parameters: z.object({
|
|
513
|
+
componentId: z.string(),
|
|
514
|
+
component: z.object({
|
|
515
|
+
type: z.string(),
|
|
516
|
+
props: z.record(z.string(), z.unknown()),
|
|
517
|
+
}),
|
|
518
|
+
reason: z.string(),
|
|
519
|
+
}),
|
|
520
|
+
execute: async ({ componentId, component, reason }) => {
|
|
521
|
+
return {
|
|
522
|
+
__modifyComponent: true,
|
|
523
|
+
componentId,
|
|
524
|
+
component: { id: componentId, type: component.type, props: component.props },
|
|
525
|
+
reason,
|
|
526
|
+
};
|
|
527
|
+
},
|
|
528
|
+
}),
|
|
529
|
+
modify_page: tool({
|
|
530
|
+
description: 'Override the composition of the current page (component order, layout).',
|
|
531
|
+
parameters: z.object({
|
|
532
|
+
pageId: z.string(),
|
|
533
|
+
page: z.object({
|
|
534
|
+
layout: z.enum(['single-column', 'two-column']).optional(),
|
|
535
|
+
main: z.array(z.string()).optional(),
|
|
536
|
+
sidebar: z.array(z.string()).optional(),
|
|
537
|
+
}),
|
|
538
|
+
reason: z.string(),
|
|
539
|
+
}),
|
|
540
|
+
execute: async ({ pageId, page, reason }) => {
|
|
541
|
+
return { __modifyPage: true, pageId, page, reason };
|
|
542
|
+
},
|
|
543
|
+
}),
|
|
544
|
+
create_page: tool({
|
|
545
|
+
description: `Create a custom page. Uses the same structure as definePage().
|
|
546
|
+
|
|
547
|
+
Example — list page:
|
|
548
|
+
{
|
|
549
|
+
pageId: "custom/customer-group-analysis",
|
|
550
|
+
title: "Analyse Customer Groups",
|
|
551
|
+
icon: "BarChart3",
|
|
552
|
+
spec: {
|
|
553
|
+
header: { title: "Analyse Customer Groups" },
|
|
554
|
+
main: [
|
|
555
|
+
{
|
|
556
|
+
type: "DataTable",
|
|
557
|
+
query: { graph: { entity: "customerGroup", fields: ["name", "customers", "created_at"], pagination: { limit: 20 } } },
|
|
558
|
+
columns: [
|
|
559
|
+
{ key: "name", label: "Nom", format: "highlight" },
|
|
560
|
+
{ key: "customers", label: "Customers", type: "count" },
|
|
561
|
+
{ key: "created_at", label: "Créé", format: "date" }
|
|
562
|
+
],
|
|
563
|
+
searchable: true
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
DEFAULT FILTERS (always-applied, server-side) — put them under query.graph.filters.
|
|
570
|
+
This is what you want when the user asks for a page that lists "only X" or "X with Y".
|
|
571
|
+
Example — "customers with an account":
|
|
572
|
+
{
|
|
573
|
+
type: "DataTable",
|
|
574
|
+
query: {
|
|
575
|
+
graph: {
|
|
576
|
+
entity: "customer",
|
|
577
|
+
fields: ["first_name", "last_name", "email", "has_account", "created_at"],
|
|
578
|
+
filters: { has_account: true },
|
|
579
|
+
sort: { field: "created_at", order: "desc" },
|
|
580
|
+
pagination: { limit: 20 }
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
columns: [...]
|
|
584
|
+
}
|
|
585
|
+
Filter values: scalars (string, number, boolean) → equality; arrays → IN; null → IS NULL;
|
|
586
|
+
operator objects → { $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $null, $notnull }.
|
|
587
|
+
Dotted keys filter through relations: { "customer.email": "x@y.com" }.
|
|
588
|
+
|
|
589
|
+
INTERACTIVE FILTER CHIPS (user clicks to filter) — block-level "filters" prop, NOT query.graph.filters:
|
|
590
|
+
{ type: "DataTable", query: {...}, filters: [{ key: "status", label: "Status", type: "select", options: [...] }] }
|
|
591
|
+
Only use this when the user explicitly wants UI controls. For "show me X with Y", use query.graph.filters instead.
|
|
592
|
+
|
|
593
|
+
To include relation counts, add the relation name to fields (e.g. "customers" for M:N).
|
|
594
|
+
Arrays are displayed as counts in columns with type: "count".
|
|
595
|
+
|
|
596
|
+
StatsCard blocks are supported when backed by a HogQL query that returns a single row with named columns (each column becomes a metric). Example:
|
|
597
|
+
{
|
|
598
|
+
type: "StatsCard",
|
|
599
|
+
query: { hogql: { query: "SELECT COUNT(*) AS total, COUNT(DISTINCT distinct_id) AS unique_users FROM events WHERE toDate(timestamp) = today() LIMIT 1" } },
|
|
600
|
+
metrics: [
|
|
601
|
+
{ label: "Total events today", key: "total" },
|
|
602
|
+
{ label: "Unique users today", key: "unique_users" }
|
|
603
|
+
]
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
IMPORTANT: Don't create routes that start with the same path as existing pages (e.g. don't use /customer-groups if that route exists).`,
|
|
607
|
+
parameters: z.object({
|
|
608
|
+
pageId: z.string().describe('Must start with "custom/"'),
|
|
609
|
+
title: z.string(),
|
|
610
|
+
icon: z.string().optional(),
|
|
611
|
+
spec: z.object({
|
|
612
|
+
header: z
|
|
613
|
+
.object({
|
|
614
|
+
title: z.string(),
|
|
615
|
+
actions: z.array(z.string()).optional(),
|
|
616
|
+
})
|
|
617
|
+
.optional(),
|
|
618
|
+
main: z.array(z.record(z.string(), z.unknown())),
|
|
619
|
+
sidebar: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
620
|
+
}),
|
|
621
|
+
}),
|
|
622
|
+
execute: async ({ pageId, title, icon, spec }) => {
|
|
623
|
+
const slug = pageId.replace(/^custom\//, '');
|
|
624
|
+
const route = `/${slug}`;
|
|
625
|
+
// Map AI type names to renderer type names
|
|
626
|
+
const mapType = (t) => {
|
|
627
|
+
const map = { DataTable: 'EntityTable', datatable: 'EntityTable' };
|
|
628
|
+
return map[t] ?? t;
|
|
629
|
+
};
|
|
630
|
+
// Pass the query through unchanged. DataTableBlock / useBlockQuery dispatch via
|
|
631
|
+
// isGraphQuery / isNamedQuery / isHogQLQuery, which all require the original shape
|
|
632
|
+
// ({ graph: {...} } / { name } / { hogql }). Flattening graph queries silently drops
|
|
633
|
+
// filters, sort, and relations, and breaks the type guards.
|
|
634
|
+
const normalizeQuery = (block) => {
|
|
635
|
+
return block.query;
|
|
636
|
+
};
|
|
637
|
+
// Auto-create PageHeader component from spec.header
|
|
638
|
+
const headerComponent = spec.header
|
|
639
|
+
? {
|
|
640
|
+
id: `${pageId}-header`,
|
|
641
|
+
type: 'PageHeader',
|
|
642
|
+
props: { title: spec.header.title, actions: spec.header.actions },
|
|
643
|
+
}
|
|
644
|
+
: null;
|
|
645
|
+
const components = spec.main.map((block, i) => {
|
|
646
|
+
const b = block;
|
|
647
|
+
return {
|
|
648
|
+
id: `${pageId}-main-${i}`,
|
|
649
|
+
type: mapType(b.type),
|
|
650
|
+
props: { ...b, type: mapType(b.type), query: normalizeQuery(b) ?? b.query },
|
|
651
|
+
};
|
|
652
|
+
});
|
|
653
|
+
// Prepend header to components list
|
|
654
|
+
if (headerComponent)
|
|
655
|
+
components.unshift(headerComponent);
|
|
656
|
+
const sidebarComponents = spec.sidebar?.map((block, i) => {
|
|
657
|
+
const b = block;
|
|
658
|
+
return {
|
|
659
|
+
id: `${pageId}-sidebar-${i}`,
|
|
660
|
+
type: mapType(b.type),
|
|
661
|
+
props: { ...b, type: mapType(b.type) },
|
|
662
|
+
};
|
|
663
|
+
}) ?? [];
|
|
664
|
+
// Page-level query (PageSpec.query) — used by SpecRenderer/useSpecQuery to fire
|
|
665
|
+
// ONE top-level fetch whose result is fanned out to every block via the `data` prop.
|
|
666
|
+
// Custom pages (AI-created) are rendered through this legacy path, so block-level
|
|
667
|
+
// graph.filters / graph.sort are otherwise ignored. We propagate them from the FIRST
|
|
668
|
+
// graph block to pageQuery so default filtering and sort actually take effect.
|
|
669
|
+
// Limitation: only the first graph block's filters/sort survive — for pages with
|
|
670
|
+
// multiple independently-filtered tables, the proper fix is to migrate
|
|
671
|
+
// CustomPageWrapper from SpecRenderer to PageRenderer (BACKLOG follow-up).
|
|
672
|
+
const firstGraphBlock = spec.main.find((b) => {
|
|
673
|
+
const q = b.query;
|
|
674
|
+
return q && typeof q.graph === 'object' && q.graph !== null;
|
|
675
|
+
});
|
|
676
|
+
const firstGraph = firstGraphBlock
|
|
677
|
+
? firstGraphBlock.query.graph
|
|
678
|
+
: undefined;
|
|
679
|
+
const firstGraphPagination = firstGraph?.pagination;
|
|
680
|
+
const firstGraphSort = firstGraph?.sort;
|
|
681
|
+
const pageQuery = firstGraph?.entity
|
|
682
|
+
? {
|
|
683
|
+
entity: firstGraph.entity,
|
|
684
|
+
list: true,
|
|
685
|
+
// fields is a single string in PageSpec (comma-separated), not an array
|
|
686
|
+
...(firstGraph.fields
|
|
687
|
+
? {
|
|
688
|
+
fields: Array.isArray(firstGraph.fields)
|
|
689
|
+
? firstGraph.fields.join(',')
|
|
690
|
+
: firstGraph.fields,
|
|
691
|
+
}
|
|
692
|
+
: {}),
|
|
693
|
+
// Propagate default filters from the block-level graph query so the
|
|
694
|
+
// legacy SpecRenderer fetch includes the WHERE clause.
|
|
695
|
+
...(firstGraph.filters && typeof firstGraph.filters === 'object'
|
|
696
|
+
? { filters: firstGraph.filters }
|
|
697
|
+
: {}),
|
|
698
|
+
// QueryDef.sort uses { field, direction }, GraphQueryDef.sort uses { field, order }.
|
|
699
|
+
...(firstGraphSort?.field
|
|
700
|
+
? { sort: { field: firstGraphSort.field, direction: firstGraphSort.order ?? 'asc' } }
|
|
701
|
+
: {}),
|
|
702
|
+
...(firstGraphPagination?.limit ? { pageSize: firstGraphPagination.limit } : {}),
|
|
703
|
+
}
|
|
704
|
+
: // No graph block in the page — tiny placeholder fetch that satisfies Zod and renders a no-op.
|
|
705
|
+
// 'admin' is the user table auto-created by defineUserModel('admin') and always exists.
|
|
706
|
+
{ entity: 'admin', list: true, pageSize: 1 };
|
|
707
|
+
const page = {
|
|
708
|
+
id: pageId,
|
|
709
|
+
type: 'list',
|
|
710
|
+
layout: spec.sidebar ? 'two-column' : 'single-column',
|
|
711
|
+
route,
|
|
712
|
+
query: pageQuery,
|
|
713
|
+
main: components.map((c) => c.id),
|
|
714
|
+
...(sidebarComponents.length ? { sidebar: sidebarComponents.map((c) => c.id) } : {}),
|
|
715
|
+
};
|
|
716
|
+
const navItem = { key: pageId, label: title, path: route, icon: icon || 'SquaresPlus' };
|
|
717
|
+
return { __createPage: true, page, components: [...components, ...sidebarComponents], navItem };
|
|
718
|
+
},
|
|
719
|
+
}),
|
|
720
|
+
update_custom_page: tool({
|
|
721
|
+
description: 'Update route or label of an existing custom page.',
|
|
722
|
+
parameters: z.object({
|
|
723
|
+
pageId: z.string(),
|
|
724
|
+
route: z.string().optional(),
|
|
725
|
+
label: z.string().optional(),
|
|
726
|
+
reason: z.string(),
|
|
727
|
+
}),
|
|
728
|
+
execute: async ({ pageId, route, label, reason }) => {
|
|
729
|
+
if (!pageId.startsWith('custom/'))
|
|
730
|
+
return { error: `Only custom pages can be updated` };
|
|
731
|
+
const updates = {};
|
|
732
|
+
if (route)
|
|
733
|
+
updates.route = route;
|
|
734
|
+
if (label)
|
|
735
|
+
updates.label = label;
|
|
736
|
+
return { __updateCustomPage: true, pageId, updates, reason };
|
|
737
|
+
},
|
|
738
|
+
}),
|
|
739
|
+
delete_page: tool({
|
|
740
|
+
description: 'Delete a custom page (pageId must start with "custom/").',
|
|
741
|
+
parameters: z.object({ pageId: z.string() }),
|
|
742
|
+
execute: async ({ pageId }) => {
|
|
743
|
+
if (!pageId.startsWith('custom/'))
|
|
744
|
+
return { error: `Only custom pages can be deleted` };
|
|
745
|
+
return { __deletePage: true, pageId };
|
|
746
|
+
},
|
|
747
|
+
}),
|
|
748
|
+
reset_component: tool({
|
|
749
|
+
description: 'Reset a component override back to default.',
|
|
750
|
+
parameters: z.object({ componentId: z.string() }),
|
|
751
|
+
execute: async ({ componentId }) => ({ __resetComponent: true, componentId }),
|
|
752
|
+
}),
|
|
753
|
+
get_navigation: tool({
|
|
754
|
+
description: 'Get the REAL current navigation menu as displayed in the sidebar. Includes all default items and any overrides.',
|
|
755
|
+
parameters: z.object({}),
|
|
756
|
+
execute: async () => {
|
|
757
|
+
// Return the real navigation: override if set, otherwise the default from the code
|
|
758
|
+
const navigation = navigationOverride || defaultNavigation || [];
|
|
759
|
+
return { __getNavigation: true, navigation, isOverridden: !!navigationOverride };
|
|
760
|
+
},
|
|
761
|
+
}),
|
|
762
|
+
set_navigation: tool({
|
|
763
|
+
description: 'Replace the entire navigation menu. ALWAYS call get_navigation first to get the real current menu. Never invent routes — only use routes from get_navigation or from pages you created.',
|
|
764
|
+
parameters: z.object({
|
|
765
|
+
navigation: z.array(z.object({
|
|
766
|
+
key: z.string(),
|
|
767
|
+
label: z.string(),
|
|
768
|
+
icon: z.string(),
|
|
769
|
+
path: z.string(),
|
|
770
|
+
children: z
|
|
771
|
+
.array(z.object({ key: z.string(), label: z.string(), path: z.string(), icon: z.string().optional() }))
|
|
772
|
+
.optional(),
|
|
773
|
+
})),
|
|
774
|
+
reason: z.string(),
|
|
775
|
+
}),
|
|
776
|
+
execute: async ({ navigation, reason }) => ({ __setNavigation: true, navigation, reason }),
|
|
777
|
+
}),
|
|
778
|
+
reset_navigation: tool({
|
|
779
|
+
description: 'Reset navigation to default.',
|
|
780
|
+
parameters: z.object({}),
|
|
781
|
+
execute: async () => ({ __resetNavigation: true }),
|
|
782
|
+
}),
|
|
783
|
+
// ── PostHog analytics tool (conditional on POSTHOG_API_KEY) ──
|
|
784
|
+
//
|
|
785
|
+
// Exposed only when a PostHog personal API key is set. Lets the AI write raw HogQL
|
|
786
|
+
// queries against the PostHog data warehouse (events, persons, session_replay_events,
|
|
787
|
+
// data warehouse tables like klaviyo_*, shopify_*, stripe_*).
|
|
788
|
+
//
|
|
789
|
+
// Why a dedicated tool instead of query_entity:
|
|
790
|
+
// query_entity returns rows. For analytics questions ("how many unique visitors
|
|
791
|
+
// today?", "top 10 events by count this week") the AI would otherwise pull thousands
|
|
792
|
+
// of raw rows into its context window and crash on "prompt too long" (Claude maxes
|
|
793
|
+
// at 200k tokens, one posthog event row ≈ 2k tokens of JSON properties). HogQL's
|
|
794
|
+
// COUNT / GROUP BY / DATE functions return tiny aggregated result sets instead.
|
|
795
|
+
//
|
|
796
|
+
// Safety: SELECT-only. Any query not starting with SELECT (INSERT, UPDATE, DELETE,
|
|
797
|
+
// DROP, TRUNCATE, ALTER, CREATE) is refused client-side before it hits the API.
|
|
798
|
+
...(process.env.POSTHOG_API_KEY
|
|
799
|
+
? {
|
|
800
|
+
query_posthog_hogql: tool({
|
|
801
|
+
description: `Run a raw HogQL SELECT query against the PostHog data warehouse. USE THIS for any analytics question that requires aggregation, counting, grouping, date filtering, or joining across PostHog tables — NEVER use query_entity for analytics because it returns individual rows and will overflow the context window.
|
|
802
|
+
|
|
803
|
+
Available tables (ClickHouse):
|
|
804
|
+
events — every PostHog event (fields: uuid, event, distinct_id, timestamp, properties, person_id, ...)
|
|
805
|
+
persons — identified persons (fields: id, distinct_id, created_at, properties)
|
|
806
|
+
session_replay_events — session recordings (fields: session_id, distinct_id, timestamp, click_count, ...)
|
|
807
|
+
groups — group analytics rows
|
|
808
|
+
(plus any data warehouse table synced in this PostHog project — klaviyo_campaign, shopify_order, stripe_charge, etc.)
|
|
809
|
+
|
|
810
|
+
HogQL tips (differs from standard SQL):
|
|
811
|
+
- Date helpers: today(), now(), toDate(col), toStartOfDay(col), dateDiff('day', a, b)
|
|
812
|
+
- JSON property access: properties.$current_url (dotted, no quotes)
|
|
813
|
+
- Always LIMIT results (even aggregations — add LIMIT 1000 as a safety cap)
|
|
814
|
+
- COUNT(DISTINCT col) for unique counts
|
|
815
|
+
|
|
816
|
+
Examples:
|
|
817
|
+
• Unique visitors today:
|
|
818
|
+
SELECT COUNT(DISTINCT distinct_id) AS unique_visitors FROM events WHERE toDate(timestamp) = today()
|
|
819
|
+
• Top 10 events by volume this week:
|
|
820
|
+
SELECT event, COUNT(*) AS total FROM events WHERE timestamp > now() - INTERVAL 7 DAY GROUP BY event ORDER BY total DESC LIMIT 10
|
|
821
|
+
• Sessions today:
|
|
822
|
+
SELECT COUNT(DISTINCT properties.$session_id) FROM events WHERE toDate(timestamp) = today()
|
|
823
|
+
• Klaviyo campaigns from warehouse:
|
|
824
|
+
SELECT name, send_time FROM klaviyo_campaign ORDER BY send_time DESC LIMIT 20`,
|
|
825
|
+
parameters: z.object({
|
|
826
|
+
query: z
|
|
827
|
+
.string()
|
|
828
|
+
.describe('A HogQL SELECT query. Must start with SELECT. Always include a LIMIT clause.'),
|
|
829
|
+
}),
|
|
830
|
+
execute: async ({ query }) => {
|
|
831
|
+
const trimmed = query.trim();
|
|
832
|
+
// Allow both bare SELECT and CTE queries starting with WITH (which eventually
|
|
833
|
+
// SELECT anyway). Refuse anything else (INSERT, UPDATE, DELETE, DROP, ALTER, …).
|
|
834
|
+
if (!/^(with|select)\b/i.test(trimmed)) {
|
|
835
|
+
return { error: 'Only SELECT or WITH…SELECT queries are allowed. This tool is read-only.' };
|
|
836
|
+
}
|
|
837
|
+
const host = process.env.POSTHOG_HOST ?? 'https://eu.i.posthog.com';
|
|
838
|
+
const key = process.env.POSTHOG_API_KEY;
|
|
839
|
+
try {
|
|
840
|
+
const res = await fetch(`${host}/api/projects/@current/query/`, {
|
|
841
|
+
method: 'POST',
|
|
842
|
+
headers: {
|
|
843
|
+
Authorization: `Bearer ${key}`,
|
|
844
|
+
'Content-Type': 'application/json',
|
|
845
|
+
},
|
|
846
|
+
body: JSON.stringify({ query: { kind: 'HogQLQuery', query: trimmed } }),
|
|
847
|
+
});
|
|
848
|
+
if (!res.ok) {
|
|
849
|
+
const body = await res.text();
|
|
850
|
+
return {
|
|
851
|
+
error: `PostHog HogQL ${res.status}: ${body}`,
|
|
852
|
+
hint: 'Check HogQL syntax. Common mistakes: missing LIMIT, using SQL functions not in HogQL, referencing non-existent columns. Use describe_entity or retry with a simpler query.',
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
const data = (await res.json());
|
|
856
|
+
if (!data.results || !data.columns) {
|
|
857
|
+
return { columns: [], rows: [], note: 'Query returned no results structure.' };
|
|
858
|
+
}
|
|
859
|
+
// Cap result rows at 200 to keep context manageable even if LIMIT is missing
|
|
860
|
+
const rows = data.results.slice(0, 200).map((row) => {
|
|
861
|
+
const obj = {};
|
|
862
|
+
data.columns?.forEach((col, idx) => {
|
|
863
|
+
obj[col] = row[idx];
|
|
864
|
+
});
|
|
865
|
+
return obj;
|
|
866
|
+
});
|
|
867
|
+
return {
|
|
868
|
+
columns: data.columns,
|
|
869
|
+
rows,
|
|
870
|
+
rowCount: data.results.length,
|
|
871
|
+
truncated: data.results.length > 200,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
catch (err) {
|
|
875
|
+
return { error: err.message };
|
|
876
|
+
}
|
|
877
|
+
},
|
|
878
|
+
}),
|
|
879
|
+
}
|
|
880
|
+
: {}),
|
|
881
|
+
// ── CQRS command tools (auto-discovered) ─────────────────────
|
|
882
|
+
...commandTools,
|
|
883
|
+
// ── CQRS query tools (auto-discovered) ───────────────────────
|
|
884
|
+
...queryTools,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
// ── System prompt ────────────────────────────────────────────────
|
|
888
|
+
function buildSystemPrompt(moduleNames, commandNames, linkGraph = [], queries = []) {
|
|
889
|
+
// Build relation descriptions: for each entity, list what relations are available
|
|
890
|
+
const relationsByEntity = new Map();
|
|
891
|
+
for (const l of linkGraph) {
|
|
892
|
+
const isMany = l.cardinality === 'M:N';
|
|
893
|
+
const leftRel = isMany ? `${l.right}s` : l.right;
|
|
894
|
+
const rightRel = isMany ? `${l.left}s` : l.left;
|
|
895
|
+
const leftDescs = relationsByEntity.get(l.left) ?? [];
|
|
896
|
+
leftDescs.push(`${leftRel} (${l.cardinality})`);
|
|
897
|
+
relationsByEntity.set(l.left, leftDescs);
|
|
898
|
+
const rightDescs = relationsByEntity.get(l.right) ?? [];
|
|
899
|
+
rightDescs.push(`${rightRel} (${l.cardinality})`);
|
|
900
|
+
relationsByEntity.set(l.right, rightDescs);
|
|
901
|
+
}
|
|
902
|
+
return `You are an AI assistant for a Manta admin dashboard.
|
|
903
|
+
|
|
904
|
+
## Available entities (camelCase — use these EXACT names)
|
|
905
|
+
|
|
906
|
+
${moduleNames
|
|
907
|
+
.map((n) => {
|
|
908
|
+
const rels = relationsByEntity.get(n);
|
|
909
|
+
return rels ? `- ${n} — relations: ${rels.join(', ')}` : `- ${n}`;
|
|
910
|
+
})
|
|
911
|
+
.join('\n')}
|
|
912
|
+
|
|
913
|
+
## Available commands (writes — mutations)
|
|
914
|
+
|
|
915
|
+
${commandNames.map((n) => `- command_${n}: Execute the "${n}" command`).join('\n')}
|
|
916
|
+
|
|
917
|
+
${queries.length > 0
|
|
918
|
+
? `## Available queries (reads — no side effects)
|
|
919
|
+
|
|
920
|
+
${queries.map((q) => `- query_${q.name.replace(/[-:]/g, '_')}: ${q.description}`).join('\n')}
|
|
921
|
+
`
|
|
922
|
+
: ''}
|
|
923
|
+
## Tool selection rules — IMPORTANT
|
|
924
|
+
|
|
925
|
+
- **Reads** (listing, fetching, analytics) → use \`query_*\` tools OR \`query_entity\` for generic entity queries.
|
|
926
|
+
- **Writes** (create / update / delete / side-effecting actions) → use \`command_*\` tools.
|
|
927
|
+
- **Visualizations** → use \`render_component\`.
|
|
928
|
+
- When both a specialized \`query_*\` tool and \`query_entity\` can answer the question, prefer the specialized one because it knows the right backend (e.g. external analytics, search index).
|
|
929
|
+
|
|
930
|
+
## CRITICAL — Analytics / event-based questions: NEVER guess event names
|
|
931
|
+
|
|
932
|
+
When the user asks any question involving **analytics, funnels, conversion, counting, acheteurs,
|
|
933
|
+
achats, visiteurs, sessions, orders, purchases, commandes, signups, clicks, impressions**, you are
|
|
934
|
+
absolutely forbidden from guessing event names based on natural-language intent. Every project
|
|
935
|
+
names its events differently — "order_completed", "checkout_completed", "Order Placed",
|
|
936
|
+
"purchase", "shopify_order_created" are all possible, and YOU DO NOT KNOW which one this project
|
|
937
|
+
uses until you verify.
|
|
938
|
+
|
|
939
|
+
**Mandatory workflow before writing any analytics query:**
|
|
940
|
+
|
|
941
|
+
1. **Discover the real event landscape first.** Run:
|
|
942
|
+
\`\`\`sql
|
|
943
|
+
SELECT event, COUNT(*) AS n
|
|
944
|
+
FROM events
|
|
945
|
+
WHERE toDate(timestamp) = today()
|
|
946
|
+
GROUP BY event
|
|
947
|
+
ORDER BY n DESC
|
|
948
|
+
LIMIT 50
|
|
949
|
+
\`\`\`
|
|
950
|
+
(Widen the date range if today's data is sparse.)
|
|
951
|
+
|
|
952
|
+
2. **Pick the exact event name** from what the discovery returned. If nothing in the list
|
|
953
|
+
obviously matches the user's concept, **STOP** and ask the user: "Here are the events tracked
|
|
954
|
+
in your project: [list]. Which one corresponds to 'acheté' / 'purchase' / 'signup' in your
|
|
955
|
+
tracking?" — do NOT proceed with a guess.
|
|
956
|
+
|
|
957
|
+
3. **Use the exact event name verbatim** in your query and in your written synthesis. If you
|
|
958
|
+
queried \`event = 'checkout_started'\`, you MUST write "checkout_started" or "démarré le
|
|
959
|
+
checkout" in the answer, NEVER "ont acheté", "ont commandé", or any other paraphrase that
|
|
960
|
+
changes the semantic meaning. Starting a checkout is NOT buying.
|
|
961
|
+
|
|
962
|
+
4. **Never silently fall back** to \`LIKE '%checkout%'\`, \`event IN (list_of_guesses)\`, or
|
|
963
|
+
partial matches when an exact name returns 0. A zero result means "this concept is tracked
|
|
964
|
+
under a different name" — go back to step 1 or ask the user.
|
|
965
|
+
|
|
966
|
+
5. **Label mapping table in your synthesis**: when presenting any analytics result, include a
|
|
967
|
+
small "Data source" line that makes the event → metric mapping explicit. Example:
|
|
968
|
+
> Data source: \`event = 'checkout_completed'\` over today, COUNT(DISTINCT distinct_id).
|
|
969
|
+
|
|
970
|
+
**Why this matters:** confusing \`checkout_started\` with "ont acheté" produces a 6x overstatement
|
|
971
|
+
of conversion. Confusing \`product_added_to_cart\` with "achats" produces 17x overstatement.
|
|
972
|
+
Every analytics hallucination in this system has historically come from semantic guessing between
|
|
973
|
+
French/English natural language and raw event names. Treat event names as foreign words you
|
|
974
|
+
cannot translate — only transliterate.
|
|
975
|
+
|
|
976
|
+
## HogQL syntax cheatsheet — IMPORTANT
|
|
977
|
+
|
|
978
|
+
HogQL is a **ClickHouse dialect**, NOT PostgreSQL or MySQL. Using the wrong syntax is the #1 cause
|
|
979
|
+
of retry loops. Memorize these:
|
|
980
|
+
|
|
981
|
+
**JSON access — use ClickHouse functions, NOT \`->>\`, \`->\`, or \`JSON_EXTRACT_*\`:**
|
|
982
|
+
\`\`\`sql
|
|
983
|
+
-- ❌ WRONG (PostgreSQL / MySQL syntax):
|
|
984
|
+
col->>'$.key'
|
|
985
|
+
col->'$.key'
|
|
986
|
+
JSON_EXTRACT_STRING(col, '$.key')
|
|
987
|
+
JSON_EXTRACT(col, '$.key')
|
|
988
|
+
|
|
989
|
+
-- ✅ CORRECT (HogQL / ClickHouse):
|
|
990
|
+
JSONExtractString(col, 'key') -- single-level
|
|
991
|
+
JSONExtractString(col, 'parent', 'child') -- nested via variadic args
|
|
992
|
+
JSONExtractInt(col, 'count') -- typed extractors
|
|
993
|
+
JSONExtractFloat(col, 'price')
|
|
994
|
+
JSONExtractBool(col, 'active')
|
|
995
|
+
JSONExtractRaw(col, 'obj') -- returns raw JSON sub-object
|
|
996
|
+
properties.foo -- shorthand for direct property access (events.properties only)
|
|
997
|
+
\`\`\`
|
|
998
|
+
|
|
999
|
+
**COUNT(*) with multiple tables (JOINs):**
|
|
1000
|
+
\`\`\`sql
|
|
1001
|
+
-- ❌ WRONG: "Cannot use '*' without table name when there are multiple tables"
|
|
1002
|
+
SELECT COUNT(*) FROM events e JOIN persons p ON ...
|
|
1003
|
+
|
|
1004
|
+
-- ✅ CORRECT: alias a column or use COUNT(1)
|
|
1005
|
+
SELECT COUNT(e.id) FROM events e JOIN persons p ON ...
|
|
1006
|
+
SELECT COUNT(1) FROM events e JOIN persons p ON ...
|
|
1007
|
+
\`\`\`
|
|
1008
|
+
|
|
1009
|
+
**CASE / CONDITIONAL:**
|
|
1010
|
+
\`\`\`sql
|
|
1011
|
+
-- HogQL accepts standard SQL CASE WHEN, BUT every WHEN must have a matching THEN
|
|
1012
|
+
-- and you MUST include ELSE to be safe (HogQL lowers CASE to multiIf which requires
|
|
1013
|
+
-- odd arg count).
|
|
1014
|
+
|
|
1015
|
+
-- ✅ CORRECT:
|
|
1016
|
+
CASE WHEN x > 0 THEN 'positive' ELSE 'non-positive' END
|
|
1017
|
+
|
|
1018
|
+
-- ❌ WRONG (no ELSE):
|
|
1019
|
+
CASE WHEN x > 0 THEN 'positive' END
|
|
1020
|
+
\`\`\`
|
|
1021
|
+
|
|
1022
|
+
**Date / time functions:**
|
|
1023
|
+
\`\`\`sql
|
|
1024
|
+
today() -- returns date of today in project timezone
|
|
1025
|
+
now() -- current timestamp
|
|
1026
|
+
toDate(timestamp_col) -- extract date from timestamp
|
|
1027
|
+
toStartOfDay(col), toStartOfHour(col), toStartOfMonth(col)
|
|
1028
|
+
dateDiff('day', a, b) -- integer diff between two dates
|
|
1029
|
+
timestamp_col > now() - INTERVAL 7 DAY -- relative filter
|
|
1030
|
+
\`\`\`
|
|
1031
|
+
|
|
1032
|
+
**String matching:**
|
|
1033
|
+
\`\`\`sql
|
|
1034
|
+
col ILIKE '%foo%' -- case-insensitive LIKE — supported
|
|
1035
|
+
col LIKE '%foo%' -- case-sensitive
|
|
1036
|
+
match(col, 'regex') -- regex match (ClickHouse-native)
|
|
1037
|
+
\`\`\`
|
|
1038
|
+
|
|
1039
|
+
**Aggregations with filters — use \`countIf\` / \`sumIf\` instead of CASE WHEN:**
|
|
1040
|
+
\`\`\`sql
|
|
1041
|
+
-- ✅ Idiomatic HogQL / ClickHouse:
|
|
1042
|
+
SELECT
|
|
1043
|
+
countIf(event = 'checkout_completed') AS purchases,
|
|
1044
|
+
countIf(event = 'page_viewed') AS page_views,
|
|
1045
|
+
sumIf(revenue, event = 'order_placed') AS total_revenue
|
|
1046
|
+
FROM events
|
|
1047
|
+
WHERE toDate(timestamp) = today()
|
|
1048
|
+
\`\`\`
|
|
1049
|
+
|
|
1050
|
+
**Always include a LIMIT** even on aggregation queries (safety net, defaults to 200 on truncation).
|
|
1051
|
+
|
|
1052
|
+
If a query returns a validation error, **read the error message carefully** — it tells you which
|
|
1053
|
+
function or syntax isn't supported — then rewrite using the HogQL equivalent above. Do NOT retry
|
|
1054
|
+
the exact same query.
|
|
1055
|
+
|
|
1056
|
+
## Property names follow the same rule
|
|
1057
|
+
|
|
1058
|
+
Same contract for properties: never assume \`$order_id\`, \`$revenue\`, \`total\`, \`value\` exist
|
|
1059
|
+
without verifying. To inspect the shape of an event's properties, sample one row first:
|
|
1060
|
+
\`\`\`sql
|
|
1061
|
+
SELECT properties FROM events WHERE event = 'checkout_completed' LIMIT 1
|
|
1062
|
+
\`\`\`
|
|
1063
|
+
Then use only properties you actually saw in the sample.
|
|
1064
|
+
|
|
1065
|
+
## PostHog Data Warehouse tables (Klaviyo, Shopify, Stripe, HubSpot, …)
|
|
1066
|
+
|
|
1067
|
+
When the user asks about **email marketing, campaigns, CRM, subscribers, lists, flows, metrics,
|
|
1068
|
+
Shopify orders, Stripe charges, or any non-event business data**, the answer often lives in
|
|
1069
|
+
PostHog's Data Warehouse (synced external sources), NOT in the \`events\` or \`persons\` tables.
|
|
1070
|
+
|
|
1071
|
+
**Warehouse tables use SCHEMA.TABLE dotted notation**, not underscore-prefixed names. Query them
|
|
1072
|
+
with the same \`query_posthog_hogql\` tool. Examples of real tables that exist in a Klaviyo-synced
|
|
1073
|
+
project:
|
|
1074
|
+
|
|
1075
|
+
\`\`\`
|
|
1076
|
+
klaviyo.profiles -- contacts (id, email, first_name, last_name, phone_number, location, …)
|
|
1077
|
+
klaviyo.events -- per-profile activity (opened email, clicked, placed order, …)
|
|
1078
|
+
klaviyo.email_campaigns -- campaigns (id, name, send_time, subject, …)
|
|
1079
|
+
klaviyo.sms_campaigns
|
|
1080
|
+
klaviyo.flows -- automation flows
|
|
1081
|
+
klaviyo.lists -- subscriber lists
|
|
1082
|
+
klaviyo.metrics -- custom metrics defined in Klaviyo
|
|
1083
|
+
\`\`\`
|
|
1084
|
+
|
|
1085
|
+
Shopify / Stripe / HubSpot / other sources follow the same pattern when synced: \`shopify.orders\`,
|
|
1086
|
+
\`shopify.customers\`, \`stripe.charges\`, \`hubspot.contacts\`, etc.
|
|
1087
|
+
|
|
1088
|
+
**Discovery workflow for warehouse queries:**
|
|
1089
|
+
|
|
1090
|
+
1. **Don't guess the schema/table name.** Users may or may not have synced a given source. Before
|
|
1091
|
+
writing a business query, probe with a tiny query that also serves as existence check:
|
|
1092
|
+
\`\`\`sql
|
|
1093
|
+
SELECT COUNT(*) FROM klaviyo.profiles
|
|
1094
|
+
\`\`\`
|
|
1095
|
+
If this returns \`Unknown table\`, the source isn't synced — tell the user rather than inventing.
|
|
1096
|
+
|
|
1097
|
+
2. **Discover column names with a sample.** Each warehouse source has its own shape, and you
|
|
1098
|
+
don't know column names a priori:
|
|
1099
|
+
\`\`\`sql
|
|
1100
|
+
SELECT * FROM klaviyo.email_campaigns LIMIT 1
|
|
1101
|
+
\`\`\`
|
|
1102
|
+
The tool returns both \`columns\` and \`rows\` — read the columns list before writing your real
|
|
1103
|
+
query. Columns from Klaviyo differ from Shopify differ from Stripe.
|
|
1104
|
+
|
|
1105
|
+
3. **Cross-source joins are possible.** HogQL can join a warehouse table with \`events\` or
|
|
1106
|
+
\`persons\` in a single query (e.g. correlate Klaviyo email clicks with PostHog checkout events).
|
|
1107
|
+
Use \`JOIN\` with explicit ON clauses, matching on email or distinct_id.
|
|
1108
|
+
|
|
1109
|
+
4. **Apply the same event-name rule to warehouse tables.** Klaviyo's events table has an \`event\`-
|
|
1110
|
+
equivalent column (often named \`metric_id\` → join to \`klaviyo.metrics.name\`). Never guess a
|
|
1111
|
+
metric name like "Placed Order" or "Opened Email" — list distinct values first.
|
|
1112
|
+
|
|
1113
|
+
## How to query data — query_entity tool
|
|
1114
|
+
|
|
1115
|
+
Use query_entity to read data. Entity names are camelCase (e.g. "customerGroup", NOT "customer_group").
|
|
1116
|
+
|
|
1117
|
+
### Including related entities
|
|
1118
|
+
|
|
1119
|
+
To include relations, add the relation name to fields:
|
|
1120
|
+
|
|
1121
|
+
query_entity({ entity: "customerGroup", fields: ["name", "customers"] })
|
|
1122
|
+
→ Returns: { data: [{ name: "VIP", customers: [{...}, {...}], ... }] }
|
|
1123
|
+
|
|
1124
|
+
The relation name is the LINKED entity name:
|
|
1125
|
+
${linkGraph
|
|
1126
|
+
.map((l) => {
|
|
1127
|
+
const isMany = l.cardinality === 'M:N';
|
|
1128
|
+
return ` - On ${l.left}: use "${isMany ? `${l.right}s` : l.right}" to get linked ${l.right} entities
|
|
1129
|
+
- On ${l.right}: use "${isMany ? `${l.left}s` : l.left}" to get linked ${l.left} entities`;
|
|
1130
|
+
})
|
|
1131
|
+
.join('\n')}
|
|
1132
|
+
|
|
1133
|
+
To count related entities, just count the array length in the response.
|
|
1134
|
+
|
|
1135
|
+
### Filters
|
|
1136
|
+
|
|
1137
|
+
Simple key-value pairs only:
|
|
1138
|
+
- Exact match: { "status": "active" }
|
|
1139
|
+
- Multiple values: { "status": ["active", "archived"] }
|
|
1140
|
+
- No operators ($ne, $gt etc.)
|
|
1141
|
+
|
|
1142
|
+
## CRITICAL — Always include relations when analyzing relationships
|
|
1143
|
+
|
|
1144
|
+
When the user asks about relationships between entities (e.g. "how many customers per group"):
|
|
1145
|
+
- You MUST include the relation name in fields: query_entity({ entity: "customerGroup", fields: ["name", "customers"] })
|
|
1146
|
+
- WITHOUT the relation in fields, the response will NOT include related entities
|
|
1147
|
+
- Relations are returned as arrays. Count the length for counts.
|
|
1148
|
+
|
|
1149
|
+
## Rendering data — IMPORTANT
|
|
1150
|
+
|
|
1151
|
+
When rendering data in chat, use render_component with type "DataTable".
|
|
1152
|
+
Arrays in data items are automatically displayed as counts.
|
|
1153
|
+
|
|
1154
|
+
## Tool workflow
|
|
1155
|
+
|
|
1156
|
+
1. **query_entity** — include relation names in fields when you need related data.
|
|
1157
|
+
2. **render_component** — use DataTable for lists, InfoCard for details, StatsCard for metrics.
|
|
1158
|
+
3. **command_*** — for mutations.
|
|
1159
|
+
|
|
1160
|
+
## Creating pages
|
|
1161
|
+
|
|
1162
|
+
Use create_page with the same structure as definePage(). The spec must include:
|
|
1163
|
+
- header: { title: "Page Title" }
|
|
1164
|
+
- main: array of blocks (DataTable, InfoCard, StatsCard)
|
|
1165
|
+
|
|
1166
|
+
Each block has a \`query\` prop that supports **three shapes** depending on the data source:
|
|
1167
|
+
|
|
1168
|
+
### Shape 1 — Graph query (local entities, default)
|
|
1169
|
+
For blocks backed by a local Manta entity (customer, product, etc.):
|
|
1170
|
+
\`\`\`json
|
|
1171
|
+
{
|
|
1172
|
+
"type": "DataTable",
|
|
1173
|
+
"query": { "graph": { "entity": "customerGroup", "fields": ["name", "customers"], "pagination": { "limit": 20 } } },
|
|
1174
|
+
"columns": [{ "key": "name", "label": "Name" }, { "key": "customers", "label": "Customers", "type": "count" }],
|
|
1175
|
+
"searchable": true
|
|
1176
|
+
}
|
|
1177
|
+
\`\`\`
|
|
1178
|
+
To include relation data, add the relation name to fields. Use \`type: "count"\` on columns with array values.
|
|
1179
|
+
|
|
1180
|
+
### Shape 2 — Named query (custom handler already defined)
|
|
1181
|
+
For blocks backed by a \`defineQuery()\` TS handler on the backend (mix of sources, custom logic):
|
|
1182
|
+
\`\`\`json
|
|
1183
|
+
{
|
|
1184
|
+
"type": "DataTable",
|
|
1185
|
+
"query": { "name": "active-customers", "input": { "days": 7 } },
|
|
1186
|
+
"columns": [{ "key": "email", "label": "Email" }, { "key": "event_count", "label": "Events" }]
|
|
1187
|
+
}
|
|
1188
|
+
\`\`\`
|
|
1189
|
+
|
|
1190
|
+
### Shape 3 — HogQL query (PostHog Data Warehouse, direct)
|
|
1191
|
+
For blocks backed by **raw HogQL against the PostHog warehouse** (klaviyo.*, shopify.*, stripe.*, events, persons, etc.).
|
|
1192
|
+
Use this when the user asks for **pure analytics** that don't need to join local entities —
|
|
1193
|
+
top campaigns, active visitors, event breakdowns, top products by warehouse data, etc.
|
|
1194
|
+
\`\`\`json
|
|
1195
|
+
{
|
|
1196
|
+
"type": "DataTable",
|
|
1197
|
+
"query": {
|
|
1198
|
+
"hogql": {
|
|
1199
|
+
"query": "SELECT name, status, send_time FROM klaviyo.email_campaigns WHERE status = 'Sent' ORDER BY send_time DESC LIMIT 20"
|
|
1200
|
+
}
|
|
1201
|
+
},
|
|
1202
|
+
"columns": [
|
|
1203
|
+
{ "key": "name", "label": "Campaign" },
|
|
1204
|
+
{ "key": "status", "label": "Status" },
|
|
1205
|
+
{ "key": "send_time", "label": "Sent at", "format": "datetime" }
|
|
1206
|
+
]
|
|
1207
|
+
}
|
|
1208
|
+
\`\`\`
|
|
1209
|
+
|
|
1210
|
+
StatsCard with a HogQL query that returns a single row:
|
|
1211
|
+
\`\`\`json
|
|
1212
|
+
{
|
|
1213
|
+
"type": "StatsCard",
|
|
1214
|
+
"query": {
|
|
1215
|
+
"hogql": {
|
|
1216
|
+
"query": "SELECT COUNT(DISTINCT distinct_id) AS unique_visitors, COUNT(*) AS total_events FROM events WHERE toDate(timestamp) = today()"
|
|
1217
|
+
}
|
|
1218
|
+
},
|
|
1219
|
+
"metrics": [
|
|
1220
|
+
{ "label": "Unique visitors today", "key": "unique_visitors" },
|
|
1221
|
+
{ "label": "Total events", "key": "total_events" }
|
|
1222
|
+
]
|
|
1223
|
+
}
|
|
1224
|
+
\`\`\`
|
|
1225
|
+
|
|
1226
|
+
**Rules for the \`hogql\` shape:**
|
|
1227
|
+
- The query runs via a server-side relay at POST /api/admin/posthog/hogql — SELECT/WITH only, admin auth required.
|
|
1228
|
+
- Results are capped at 500 rows. Always include an explicit LIMIT in your query for predictability.
|
|
1229
|
+
- Columns in your HogQL \`SELECT\` become the keys that \`columns\` / \`metrics\` reference.
|
|
1230
|
+
- A HogQL query with **a single row** is auto-interpreted as a StatsCard data object; with multiple rows it becomes items for a DataTable.
|
|
1231
|
+
- Same HogQL cheatsheet applies (JSONExtractString, countIf, toDate/today, no JSON_EXTRACT, no \`->>\`).
|
|
1232
|
+
- \`:param\` placeholders in the HogQL string are substituted from route params (e.g. on \`/customers/:id\` the string \`:id\` is replaced with the current customer id).
|
|
1233
|
+
- **Never mix local + HogQL in a single block**. For mixed views, ask the user to create a \`defineQuery()\` handler (or propose the TS code), then use shape 2 referencing that name. Hybrid in-block composition is not supported.
|
|
1234
|
+
|
|
1235
|
+
## Navigation
|
|
1236
|
+
|
|
1237
|
+
- get_navigation → read current menu override (may be empty if not overridden yet)
|
|
1238
|
+
- set_navigation → replace the entire menu
|
|
1239
|
+
- reset_navigation → restore defaults
|
|
1240
|
+
|
|
1241
|
+
### CRITICAL rules for set_navigation:
|
|
1242
|
+
1. ALWAYS call get_navigation first
|
|
1243
|
+
2. If get_navigation returns empty (isOverridden: false), use the DEFAULT navigation below
|
|
1244
|
+
3. NEVER invent routes. Only use routes from the default nav or from pages YOU created with create_page
|
|
1245
|
+
4. Keep all existing items when adding/nesting — don't drop anything
|
|
1246
|
+
|
|
1247
|
+
### Default navigation (when get_navigation returns empty):
|
|
1248
|
+
- { key: "products", label: "Products", icon: "SquaresPlus", path: "/products" }
|
|
1249
|
+
- { key: "settings", label: "Settings", icon: "CogSixTooth", path: "/settings" }
|
|
1250
|
+
|
|
1251
|
+
Custom pages you created appear in a separate "Custom" section automatically. To nest a custom page under an existing item, use set_navigation with the full menu including children.
|
|
1252
|
+
|
|
1253
|
+
## Guidelines
|
|
1254
|
+
|
|
1255
|
+
- Be concise. Use visual components for data display.
|
|
1256
|
+
- Always fetch real data before displaying it.
|
|
1257
|
+
- When modifying components, include ALL existing props plus changes.`;
|
|
1258
|
+
}
|
|
1259
|
+
export function createAiChatHandler(app, moduleNames, linkGraph = []) {
|
|
1260
|
+
return async (req) => {
|
|
1261
|
+
try {
|
|
1262
|
+
const body = await getRequestBody(req);
|
|
1263
|
+
if (!body.messages || !Array.isArray(body.messages)) {
|
|
1264
|
+
return Response.json({ error: 'messages array is required' }, { status: 400 });
|
|
1265
|
+
}
|
|
1266
|
+
let commandNames = [];
|
|
1267
|
+
try {
|
|
1268
|
+
const registry = app.resolve('commandRegistry');
|
|
1269
|
+
commandNames = registry.list().map((e) => e.name);
|
|
1270
|
+
}
|
|
1271
|
+
catch {
|
|
1272
|
+
/* no registry */
|
|
1273
|
+
}
|
|
1274
|
+
// Include auto-generated entity commands
|
|
1275
|
+
try {
|
|
1276
|
+
const entityCmds = app.resolve('__entityCommandRegistry');
|
|
1277
|
+
for (const [name] of entityCmds) {
|
|
1278
|
+
if (!commandNames.includes(name))
|
|
1279
|
+
commandNames.push(name);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
/* no entity command registry */
|
|
1284
|
+
}
|
|
1285
|
+
// Collect queries from registry (defineQuery files — CQRS reads)
|
|
1286
|
+
let queryEntries = [];
|
|
1287
|
+
try {
|
|
1288
|
+
const queryRegistry = app.resolve('queryRegistry');
|
|
1289
|
+
queryEntries = queryRegistry.list().map((e) => ({ name: e.name, description: e.description }));
|
|
1290
|
+
}
|
|
1291
|
+
catch {
|
|
1292
|
+
/* no query registry */
|
|
1293
|
+
}
|
|
1294
|
+
// Build system prompt with page context
|
|
1295
|
+
let systemPrompt = buildSystemPrompt(moduleNames, commandNames, linkGraph, queryEntries);
|
|
1296
|
+
// Auto-inject the warehouse table index (Klaviyo / Shopify / Stripe / …) with full
|
|
1297
|
+
// column schemas if POSTHOG_API_KEY is set and has warehouse_table:read scope. Cached
|
|
1298
|
+
// 5 min server-side to avoid hitting the REST API on every chat request.
|
|
1299
|
+
const warehouseIndex = await getWarehouseIndexSection();
|
|
1300
|
+
if (warehouseIndex) {
|
|
1301
|
+
systemPrompt += `\n\n${warehouseIndex}`;
|
|
1302
|
+
}
|
|
1303
|
+
if (body.pageContext) {
|
|
1304
|
+
systemPrompt += `\n\n## Current page context\n\n`;
|
|
1305
|
+
systemPrompt += `Page ID: ${body.pageContext.pageId}\nRoute: ${body.pageContext.route}\n`;
|
|
1306
|
+
systemPrompt += `Main: ${JSON.stringify(body.pageContext.composition.main)}\n`;
|
|
1307
|
+
if (body.pageContext.composition.sidebar) {
|
|
1308
|
+
systemPrompt += `Sidebar: ${JSON.stringify(body.pageContext.composition.sidebar)}\n`;
|
|
1309
|
+
}
|
|
1310
|
+
systemPrompt += `\nComponents:\n`;
|
|
1311
|
+
for (const [id, comp] of Object.entries(body.pageContext.components)) {
|
|
1312
|
+
systemPrompt += `\n### ${id}\n\`\`\`json\n${JSON.stringify(comp, null, 2)}\n\`\`\`\n`;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
if (body.customPages?.length) {
|
|
1316
|
+
systemPrompt += `\n\n## Existing custom pages\n\n`;
|
|
1317
|
+
for (const cp of body.customPages) {
|
|
1318
|
+
systemPrompt += `- **${cp.label}** — pageId: \`${cp.pageId}\`, path: \`${cp.path}\`\n`;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
const model = await getModel();
|
|
1322
|
+
const { streamText } = await import('ai');
|
|
1323
|
+
const result = streamText({
|
|
1324
|
+
model: model,
|
|
1325
|
+
system: systemPrompt,
|
|
1326
|
+
messages: body.messages,
|
|
1327
|
+
tools: buildTools(app, moduleNames, linkGraph, body.navigationOverride, body.defaultNavigation),
|
|
1328
|
+
// 10 steps accommodates multi-step analytics workflows: discovery → sample → main query
|
|
1329
|
+
// → error recovery → refined query → synthesis → render_component → follow-up.
|
|
1330
|
+
// 5 was too tight for anything involving cross-table joins + HogQL syntax iterations.
|
|
1331
|
+
maxSteps: 10,
|
|
1332
|
+
// The AI SDK masks stream-time errors as "An error occurred." on the wire (security
|
|
1333
|
+
// default). Without server-side logging here, prod failures are completely invisible
|
|
1334
|
+
// — past incident: a single bad Zod schema killed the whole chat with no signal.
|
|
1335
|
+
onError: ({ error }) => {
|
|
1336
|
+
const e = error;
|
|
1337
|
+
console.error('[ai/chat][stream-error]', {
|
|
1338
|
+
message: e?.message,
|
|
1339
|
+
name: e?.name,
|
|
1340
|
+
status: e?.status ?? e?.statusCode,
|
|
1341
|
+
cause: e?.cause,
|
|
1342
|
+
stack: e?.stack?.split('\n').slice(0, 8).join('\n'),
|
|
1343
|
+
});
|
|
1344
|
+
},
|
|
1345
|
+
});
|
|
1346
|
+
return result.toDataStreamResponse();
|
|
1347
|
+
}
|
|
1348
|
+
catch (err) {
|
|
1349
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
1350
|
+
}
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
//# sourceMappingURL=chat-handler.js.map
|