lytx 0.3.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/.env.example +37 -0
- package/README.md +486 -0
- package/alchemy.run.ts +155 -0
- package/cli/bootstrap-admin.ts +284 -0
- package/cli/deploy-staging.ts +692 -0
- package/cli/import-events.ts +628 -0
- package/cli/import-sites.ts +518 -0
- package/cli/index.ts +609 -0
- package/cli/init-db.ts +269 -0
- package/cli/migrate-to-durable-objects.ts +564 -0
- package/cli/migration-worker.ts +300 -0
- package/cli/performance-test.ts +588 -0
- package/cli/pg/client.ts +4 -0
- package/cli/pg/new-site.ts +153 -0
- package/cli/rollback-durable-objects.ts +622 -0
- package/cli/seed-data.ts +459 -0
- package/cli/setup.js +18 -0
- package/cli/setup.ts +463 -0
- package/cli/validate-migration.ts +200 -0
- package/cli/wrangler-migration.jsonc +28 -0
- package/db/adapter.ts +166 -0
- package/db/analytics_engine/client.ts +0 -0
- package/db/analytics_engine/sites.ts +0 -0
- package/db/client.ts +16 -0
- package/db/d1/client.ts +8 -0
- package/db/d1/drizzle.config.ts +35 -0
- package/db/d1/migrations/0000_true_maelstrom.sql +165 -0
- package/db/d1/migrations/0001_wonderful_bloodaxe.sql +12 -0
- package/db/d1/migrations/0002_late_frightful_four.sql +1 -0
- package/db/d1/migrations/0003_cuddly_obadiah_stane.sql +16 -0
- package/db/d1/migrations/0004_mute_stardust.sql +1 -0
- package/db/d1/migrations/0005_awesome_silvermane.sql +3 -0
- package/db/d1/migrations/0006_volatile_shriek.sql +2 -0
- package/db/d1/migrations/0007_superb_lila_cheney.sql +1 -0
- package/db/d1/migrations/0008_bitter_longshot.sql +17 -0
- package/db/d1/migrations/0009_wonderful_madame_masque.sql +28 -0
- package/db/d1/migrations/meta/0000_snapshot.json +1112 -0
- package/db/d1/migrations/meta/0001_snapshot.json +1187 -0
- package/db/d1/migrations/meta/0002_snapshot.json +1194 -0
- package/db/d1/migrations/meta/0003_snapshot.json +1296 -0
- package/db/d1/migrations/meta/0004_snapshot.json +1303 -0
- package/db/d1/migrations/meta/0005_snapshot.json +1325 -0
- package/db/d1/migrations/meta/0006_snapshot.json +1339 -0
- package/db/d1/migrations/meta/0007_snapshot.json +1347 -0
- package/db/d1/migrations/meta/0008_snapshot.json +1464 -0
- package/db/d1/migrations/meta/0009_snapshot.json +1648 -0
- package/db/d1/migrations/meta/_journal.json +76 -0
- package/db/d1/schema.ts +407 -0
- package/db/d1/sites.ts +374 -0
- package/db/d1/teamAiUsage.ts +101 -0
- package/db/d1/teams.ts +127 -0
- package/db/durable/drizzle.config.ts +8 -0
- package/db/durable/durableObjectClient.ts +480 -0
- package/db/durable/events.ts +100 -0
- package/db/durable/migrations/0000_fair_bucky.sql +38 -0
- package/db/durable/migrations/meta/0000_snapshot.json +278 -0
- package/db/durable/migrations/meta/_journal.json +13 -0
- package/db/durable/migrations/migrations.js +10 -0
- package/db/durable/schema.ts +5 -0
- package/db/durable/siteDurableObject.ts +1352 -0
- package/db/durable/types.ts +53 -0
- package/db/postgres/client.ts +13 -0
- package/db/postgres/drizzle.config.ts +12 -0
- package/db/postgres/migrations/0000_brainy_sprite.sql +116 -0
- package/db/postgres/migrations/meta/0000_snapshot.json +681 -0
- package/db/postgres/migrations/meta/_journal.json +13 -0
- package/db/postgres/schema.ts +145 -0
- package/db/postgres/sites.ts +118 -0
- package/db/tranformReports.ts +595 -0
- package/db/types.ts +55 -0
- package/endpoints/api_worker.tsx +1854 -0
- package/endpoints/site_do_worker.ts +11 -0
- package/index.d.ts +63 -0
- package/index.ts +83 -0
- package/lib/auth.ts +279 -0
- package/lib/geojson/world_countries.json +45307 -0
- package/lib/random_name.ts +41 -0
- package/lib/sendMail.ts +252 -0
- package/package.json +142 -0
- package/public/favicon.ico +0 -0
- package/public/images/android-chrome-192x192.png +0 -0
- package/public/images/android-chrome-512x512.png +0 -0
- package/public/images/apple-touch-icon.png +0 -0
- package/public/images/favicon-16x16.png +0 -0
- package/public/images/favicon-32x32.png +0 -0
- package/public/images/lytx_dark_dashboard.png +0 -0
- package/public/images/lytx_light_dashboard.png +0 -0
- package/public/images/safari-pinned-tab.svg +4 -0
- package/public/logo.png +0 -0
- package/public/site.webmanifest +26 -0
- package/public/sw.js +107 -0
- package/src/Document.tsx +86 -0
- package/src/api/ai_api.ts +1156 -0
- package/src/api/authMiddleware.ts +45 -0
- package/src/api/auth_api.ts +465 -0
- package/src/api/event_labels_api.ts +193 -0
- package/src/api/events_api.ts +210 -0
- package/src/api/queueWorker.ts +303 -0
- package/src/api/reports_api.ts +278 -0
- package/src/api/seed_api.ts +288 -0
- package/src/api/sites_api.ts +904 -0
- package/src/api/tag_api.ts +458 -0
- package/src/api/tag_api_v2.ts +289 -0
- package/src/api/team_api.ts +456 -0
- package/src/app/Dashboard.tsx +1339 -0
- package/src/app/Events.tsx +974 -0
- package/src/app/Explore.tsx +312 -0
- package/src/app/Layout.tsx +58 -0
- package/src/app/Settings.tsx +1302 -0
- package/src/app/components/DashboardCard.tsx +118 -0
- package/src/app/components/EditableCell.tsx +123 -0
- package/src/app/components/EventForm.tsx +93 -0
- package/src/app/components/MarketingFooter.tsx +49 -0
- package/src/app/components/MarketingNav.tsx +150 -0
- package/src/app/components/Nav.tsx +755 -0
- package/src/app/components/NewSiteSetup.tsx +298 -0
- package/src/app/components/SQLEditor.tsx +740 -0
- package/src/app/components/SiteSelector.tsx +126 -0
- package/src/app/components/SiteTag.tsx +42 -0
- package/src/app/components/SiteTagInstallCard.tsx +241 -0
- package/src/app/components/WorldMapCard.tsx +337 -0
- package/src/app/components/charts/ChartComponents.tsx +1481 -0
- package/src/app/components/charts/EventFunnel.tsx +45 -0
- package/src/app/components/charts/EventSummary.tsx +194 -0
- package/src/app/components/charts/SankeyFlows.tsx +72 -0
- package/src/app/components/marketing/CheckIcon.tsx +16 -0
- package/src/app/components/marketing/MarketingLayout.tsx +23 -0
- package/src/app/components/marketing/SectionHeading.tsx +35 -0
- package/src/app/components/reports/AskAiWorkspace.tsx +371 -0
- package/src/app/components/reports/CreateReportStarter.tsx +74 -0
- package/src/app/components/reports/DashboardRouteFiltersContext.tsx +14 -0
- package/src/app/components/reports/DashboardToolbar.tsx +154 -0
- package/src/app/components/reports/DashboardWorkspaceLayout.tsx +63 -0
- package/src/app/components/reports/DashboardWorkspaceShell.tsx +118 -0
- package/src/app/components/reports/ReportBuilderWorkspace.tsx +76 -0
- package/src/app/components/reports/custom/CustomReportBuilderPage.tsx +1667 -0
- package/src/app/components/reports/custom/ReportWidgetChart.tsx +297 -0
- package/src/app/components/reports/custom/buildWidgetSql.ts +151 -0
- package/src/app/components/reports/custom/chartPalettes.ts +18 -0
- package/src/app/components/reports/custom/types.ts +50 -0
- package/src/app/components/reports/reportBuilderMenuItems.ts +17 -0
- package/src/app/components/reports/useDashboardToolbarControls.tsx +235 -0
- package/src/app/components/ui/AlertBanner.tsx +101 -0
- package/src/app/components/ui/Button.tsx +55 -0
- package/src/app/components/ui/Card.tsx +80 -0
- package/src/app/components/ui/Input.tsx +72 -0
- package/src/app/components/ui/Link.tsx +23 -0
- package/src/app/components/ui/ReportBuilderMenu.tsx +246 -0
- package/src/app/components/ui/ThemeToggle.tsx +54 -0
- package/src/app/constants.ts +6 -0
- package/src/app/headers.ts +33 -0
- package/src/app/providers/AuthProvider.tsx +189 -0
- package/src/app/providers/ClientProviders.tsx +18 -0
- package/src/app/providers/QueryProvider.tsx +23 -0
- package/src/app/providers/ThemeProvider.tsx +88 -0
- package/src/app/utils/chartThemes.ts +146 -0
- package/src/app/utils/keybinds.ts +96 -0
- package/src/app/utils/media.tsx +24 -0
- package/src/client.tsx +114 -0
- package/src/config/createLytxAppConfig.ts +252 -0
- package/src/config/resourceNames.ts +88 -0
- package/src/db/index.ts +67 -0
- package/src/index.css +285 -0
- package/src/lib/featureFlags.ts +69 -0
- package/src/pages/GetStarted.tsx +290 -0
- package/src/pages/Home.tsx +268 -0
- package/src/pages/Login.tsx +283 -0
- package/src/pages/PrivacyPolicy.tsx +120 -0
- package/src/pages/Signup.tsx +267 -0
- package/src/pages/TermsOfService.tsx +126 -0
- package/src/pages/VerifyEmail.tsx +56 -0
- package/src/session/durableObject.ts +7 -0
- package/src/session/siteSchema.ts +86 -0
- package/src/session/types.ts +36 -0
- package/src/templates/README.md +80 -0
- package/src/templates/cleanFunctions.js +44 -0
- package/src/templates/embedFunctions.js +52 -0
- package/src/templates/lytx-shared.ts +662 -0
- package/src/templates/lytxpixel-core.ts +144 -0
- package/src/templates/lytxpixel.ts +267 -0
- package/src/templates/lytxpixelBrowser.js +634 -0
- package/src/templates/lytxpixelBrowser.mjs +634 -0
- package/src/templates/parseData.js +12 -0
- package/src/templates/script.ts +31 -0
- package/src/templates/template.tsx +50 -0
- package/src/templates/test.js +3 -0
- package/src/templates/trackWebEvents.ts +177 -0
- package/src/templates/vendors/clickcease.ts +8 -0
- package/src/templates/vendors/google.ts +174 -0
- package/src/templates/vendors/linkedin.ts +23 -0
- package/src/templates/vendors/meta.ts +56 -0
- package/src/templates/vendors/quantcast.ts +22 -0
- package/src/templates/vendors/simplfi.ts +7 -0
- package/src/types/app-context.ts +16 -0
- package/src/utilities/dashboardParams.ts +188 -0
- package/src/utilities/dashboardQueries.ts +537 -0
- package/src/utilities/dashboardTransforms.ts +167 -0
- package/src/utilities/dataValidation.ts +414 -0
- package/src/utilities/detector.ts +73 -0
- package/src/utilities/encrypt.ts +103 -0
- package/src/utilities/index.ts +13 -0
- package/src/utilities/parser.ts +117 -0
- package/src/utilities/performanceMonitoring.ts +570 -0
- package/src/utilities/route_interuptors.ts +24 -0
- package/src/worker.tsx +675 -0
- package/tsconfig.json +78 -0
- package/types/env.d.ts +16 -0
- package/types/rw.d.ts +7 -0
- package/types/shims.d.ts +53 -0
- package/types/vite.d.ts +19 -0
- package/vite/vite-plugin-pixel-bundle.ts +126 -0
- package/vite.config.ts +53 -0
- package/worker-configuration.d.ts +8401 -0
|
@@ -0,0 +1,1156 @@
|
|
|
1
|
+
import { env } from "cloudflare:workers";
|
|
2
|
+
import { route } from "rwsdk/router";
|
|
3
|
+
import type { RequestInfo } from "rwsdk/worker";
|
|
4
|
+
import { convertToModelMessages, generateText, streamText, tool } from "ai";
|
|
5
|
+
import { IS_DEV } from "rwsdk/constants";
|
|
6
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
import type { AppContext } from "@/types/app-context";
|
|
10
|
+
import { getSiteFromContext } from "@/api/authMiddleware";
|
|
11
|
+
import {
|
|
12
|
+
getDurableDatabaseStub,
|
|
13
|
+
getMetricsFromDurableObject,
|
|
14
|
+
getStatsFromDurableObject,
|
|
15
|
+
getTimeSeriesFromDurableObject,
|
|
16
|
+
} from "@db/durable/durableObjectClient";
|
|
17
|
+
import {
|
|
18
|
+
getTeamAiUsageForUtcDay,
|
|
19
|
+
trackTeamAiUsage,
|
|
20
|
+
type TrackTeamAiUsageInput,
|
|
21
|
+
} from "@db/d1/teamAiUsage";
|
|
22
|
+
import { parseDateParam, parseSiteIdParam } from "@/utilities/dashboardParams";
|
|
23
|
+
|
|
24
|
+
type AiConfig = {
|
|
25
|
+
baseURL: string;
|
|
26
|
+
model: string;
|
|
27
|
+
apiKey: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type NivoChartType = "bar" | "line" | "pie";
|
|
31
|
+
|
|
32
|
+
const DEFAULT_AI_MODEL = "gpt-5-mini";
|
|
33
|
+
const DEFAULT_AI_BASE_URL = "https://api.openai.com/v1";
|
|
34
|
+
const MAX_METRIC_LIMIT = 100;
|
|
35
|
+
|
|
36
|
+
type TokenUsage = {
|
|
37
|
+
inputTokens: number | null;
|
|
38
|
+
outputTokens: number | null;
|
|
39
|
+
totalTokens: number | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type StreamCompletionSummary = {
|
|
43
|
+
finishReason: string;
|
|
44
|
+
toolCallCount: number;
|
|
45
|
+
completionChars: number;
|
|
46
|
+
stepCount: number;
|
|
47
|
+
usageFromOnFinish: unknown;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function getAiConfigFromEnv(): AiConfig | null {
|
|
51
|
+
const baseURL = env.AI_BASE_URL?.trim() || DEFAULT_AI_BASE_URL;
|
|
52
|
+
const model = env.AI_MODEL?.trim() || DEFAULT_AI_MODEL;
|
|
53
|
+
const apiKey = env.AI_API_KEY?.trim() ?? "";
|
|
54
|
+
|
|
55
|
+
if (!apiKey) return null;
|
|
56
|
+
|
|
57
|
+
return { baseURL, model, apiKey };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function clampLimit(value: unknown, fallback = 10): number {
|
|
61
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
|
|
62
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
63
|
+
return Math.max(1, Math.min(MAX_METRIC_LIMIT, Math.floor(parsed)));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
67
|
+
return typeof value === "object" && value !== null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function asOptionalNonNegativeInt(value: unknown): number | null {
|
|
71
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
72
|
+
return Math.max(0, Math.floor(value));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof value === "bigint") {
|
|
76
|
+
if (value < 0n) return 0;
|
|
77
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
78
|
+
return Number.MAX_SAFE_INTEGER;
|
|
79
|
+
}
|
|
80
|
+
return Number(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (typeof value === "string") {
|
|
84
|
+
const parsed = Number(value);
|
|
85
|
+
if (!Number.isFinite(parsed)) return null;
|
|
86
|
+
return Math.max(0, Math.floor(parsed));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractTokenUsage(value: unknown): TokenUsage {
|
|
93
|
+
if (!isRecord(value)) {
|
|
94
|
+
return {
|
|
95
|
+
inputTokens: null,
|
|
96
|
+
outputTokens: null,
|
|
97
|
+
totalTokens: null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const inputTokens = asOptionalNonNegativeInt(
|
|
102
|
+
value.inputTokens
|
|
103
|
+
?? value.promptTokens
|
|
104
|
+
?? value.prompt_tokens,
|
|
105
|
+
);
|
|
106
|
+
const outputTokens = asOptionalNonNegativeInt(
|
|
107
|
+
value.outputTokens
|
|
108
|
+
?? value.completionTokens
|
|
109
|
+
?? value.completion_tokens,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const explicitTotal = asOptionalNonNegativeInt(
|
|
113
|
+
value.totalTokens
|
|
114
|
+
?? value.total_tokens,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const derivedTotal =
|
|
118
|
+
inputTokens !== null || outputTokens !== null
|
|
119
|
+
? (inputTokens ?? 0) + (outputTokens ?? 0)
|
|
120
|
+
: null;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
inputTokens,
|
|
124
|
+
outputTokens,
|
|
125
|
+
totalTokens: explicitTotal ?? derivedTotal,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getAiDailyTokenLimit(): number | null {
|
|
130
|
+
const rawValue = (env as unknown as Record<string, unknown>).AI_DAILY_TOKEN_LIMIT;
|
|
131
|
+
const raw = typeof rawValue === "string" ? rawValue.trim() : "";
|
|
132
|
+
if (!raw) return null;
|
|
133
|
+
const parsed = Number(raw);
|
|
134
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
135
|
+
return Math.floor(parsed);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function isTeamOverAiDailyLimit(teamId: number): Promise<{ limited: boolean; usedTokens: number; limit: number | null }> {
|
|
139
|
+
const limit = getAiDailyTokenLimit();
|
|
140
|
+
if (!limit) {
|
|
141
|
+
return { limited: false, usedTokens: 0, limit: null };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const totals = await getTeamAiUsageForUtcDay(teamId);
|
|
145
|
+
return {
|
|
146
|
+
limited: totals.totalTokens >= limit,
|
|
147
|
+
usedTokens: totals.totalTokens,
|
|
148
|
+
limit,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function trackAiUsageSafely(input: TrackTeamAiUsageInput) {
|
|
153
|
+
try {
|
|
154
|
+
await trackTeamAiUsage(input);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (IS_DEV) {
|
|
157
|
+
console.warn("Failed to persist AI usage", {
|
|
158
|
+
requestId: input.request_id,
|
|
159
|
+
error,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function escapeRegExp(value: string) {
|
|
166
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function truncateUtf8(input: string, maxBytes: number): string {
|
|
170
|
+
const encoder = new TextEncoder();
|
|
171
|
+
const bytes = encoder.encode(input);
|
|
172
|
+
if (bytes.length <= maxBytes) return input;
|
|
173
|
+
|
|
174
|
+
const decoded = new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, maxBytes));
|
|
175
|
+
return decoded;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function previewText(input: string, maxLength = 140): string {
|
|
179
|
+
const normalized = input.replace(/\s+/g, " ").trim();
|
|
180
|
+
if (normalized.length <= maxLength) return normalized;
|
|
181
|
+
return `${normalized.slice(0, maxLength)}...`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isPrivateNetworkHostname(hostname: string): boolean {
|
|
185
|
+
const lower = hostname.toLowerCase();
|
|
186
|
+
if (lower === "localhost" || lower.endsWith(".localhost")) return true;
|
|
187
|
+
|
|
188
|
+
const ipv4Match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(lower);
|
|
189
|
+
if (!ipv4Match) return false;
|
|
190
|
+
|
|
191
|
+
const octets = ipv4Match.slice(1).map((o) => parseInt(o, 10));
|
|
192
|
+
if (octets.some((o) => Number.isNaN(o) || o < 0 || o > 255)) return true;
|
|
193
|
+
|
|
194
|
+
const [a, b] = octets;
|
|
195
|
+
if (a === 10) return true;
|
|
196
|
+
if (a === 127) return true;
|
|
197
|
+
if (a === 0) return true;
|
|
198
|
+
if (a === 169 && b === 254) return true;
|
|
199
|
+
if (a === 192 && b === 168) return true;
|
|
200
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
201
|
+
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function looksLikeIPAddress(hostname: string): boolean {
|
|
206
|
+
// URL.hostname strips brackets for IPv6 (e.g. "::1")
|
|
207
|
+
if (hostname.includes(":")) return true;
|
|
208
|
+
return /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(hostname);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeUrl(input: string): URL | null {
|
|
212
|
+
const trimmed = input.trim();
|
|
213
|
+
if (!trimmed) return null;
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const url = new URL(trimmed);
|
|
217
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
218
|
+
return url;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function extractScriptSrcs(html: string): string[] {
|
|
225
|
+
const results: string[] = [];
|
|
226
|
+
const scriptTagRegex = /<script\b[^>]*\bsrc\s*=\s*("([^"]+)"|'([^']+)'|([^\s>]+))[^>]*>/gi;
|
|
227
|
+
|
|
228
|
+
for (const match of html.matchAll(scriptTagRegex)) {
|
|
229
|
+
const src = match[2] || match[3] || match[4];
|
|
230
|
+
if (src) results.push(src);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return Array.from(new Set(results));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function inferHostMatchesDomain(hostname: string, domain: string | null | undefined): boolean {
|
|
237
|
+
if (!domain) return true;
|
|
238
|
+
|
|
239
|
+
const normalizedDomain = domain.trim().toLowerCase();
|
|
240
|
+
if (!normalizedDomain) return true;
|
|
241
|
+
|
|
242
|
+
const lowerHost = hostname.toLowerCase();
|
|
243
|
+
return lowerHost === normalizedDomain || lowerHost.endsWith(`.${normalizedDomain}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function getSiteTagSystemPrompt() {
|
|
247
|
+
return `You are a web analytics instrumentation assistant.
|
|
248
|
+
|
|
249
|
+
Goal:
|
|
250
|
+
- Suggest the best DOM events to track on a given page (clicks, submits, nav, CTAs).
|
|
251
|
+
- Use privacy-safe guidance: avoid collecting PII (emails, names, phone numbers).
|
|
252
|
+
- Output should be concise and actionable.
|
|
253
|
+
|
|
254
|
+
When recommending Lytx event tracking:
|
|
255
|
+
- Use a custom event name like "cta_click" or "signup_submit".
|
|
256
|
+
- Recommend JavaScript that checks for window.lytxApi and calls:
|
|
257
|
+
window.lytxApi.trackCustomEvents(TAG_ID, "web", { custom: EVENT_NAME }, "")
|
|
258
|
+
- Avoid high-cardinality event names.
|
|
259
|
+
|
|
260
|
+
Return format:
|
|
261
|
+
- 3-6 suggested events with selectors and rationale
|
|
262
|
+
- 1 code snippet that attaches listeners
|
|
263
|
+
- A short validation note if tag is missing.`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function getAiConfig(_teamId?: number | null): AiConfig | null {
|
|
267
|
+
return getAiConfigFromEnv();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function getSchemaPrompt() {
|
|
271
|
+
return `You are an analytics data assistant.
|
|
272
|
+
|
|
273
|
+
Your job:
|
|
274
|
+
- Answer user questions about their analytics data.
|
|
275
|
+
- If the data is needed, call an available tool to fetch results.
|
|
276
|
+
- Prefer concise responses: 2-6 bullets with plain-language insights.
|
|
277
|
+
- Do not include SQL unless the user explicitly asks for SQL.
|
|
278
|
+
- Do not mention D1, Durable Object internals, or Postgres unless the user explicitly asks about implementation details.
|
|
279
|
+
- If the user explicitly asks for a chart/graph/visualization, call get_nivo_chart_data.
|
|
280
|
+
- Do not call get_nivo_chart_data unless the user asks for a visual/chart.
|
|
281
|
+
- When you return a chart, also include a short plain-language summary of what the chart shows.
|
|
282
|
+
|
|
283
|
+
Data schema (core tables):
|
|
284
|
+
|
|
285
|
+
D1 (SQLite / Durable Object sources):
|
|
286
|
+
- team: { id, name, uuid, external_id, db_adapter }
|
|
287
|
+
- team_member: { team_id, user_id, role, allowed_site_ids }
|
|
288
|
+
- sites: { site_id, uuid, tag_id, team_id, name, domain, track_web_events, external_id, site_db_adapter, createdAt }
|
|
289
|
+
- site_events: { id, team_id, site_id, tag_id, event, createdAt, page_url, client_page_url, referer, rid, device_type, browser, operating_system, country, region, city, postal, screen_width, screen_height, custom_data(json), query_params(json), bot_data(json) }
|
|
290
|
+
|
|
291
|
+
Postgres (if team/site is mapped via external_id):
|
|
292
|
+
- accounts: { account_id, name, website }
|
|
293
|
+
- sites: { site_id, account_id, domain, track_web_events }
|
|
294
|
+
- site_events: { id, account_id, site_id, tag_id, event, created_at, page_url, client_page_url, referer, rid, device_type, browser, operating_system, country, region, city, postal, screen_width, screen_height, custom_data(jsonb), query_params(jsonb), bot_data(jsonb) }
|
|
295
|
+
|
|
296
|
+
Conventions:
|
|
297
|
+
- Always filter by team scope:
|
|
298
|
+
- D1: use site_events.team_id = {{team_id}}
|
|
299
|
+
- Postgres: use site_events.account_id = {{team_external_id}}
|
|
300
|
+
- When site specific:
|
|
301
|
+
- D1: site_events.site_id = {{site_id}}
|
|
302
|
+
- Postgres: site_events.site_id = {{site_external_id}} (or {{site_id}} if not mapped)
|
|
303
|
+
- Use a date range if the question implies it (e.g. last 7/30 days).
|
|
304
|
+
- Use indexes-friendly patterns: date filter + group by + limit.
|
|
305
|
+
|
|
306
|
+
Return format:
|
|
307
|
+
- A brief explanation
|
|
308
|
+
- Results summary (if tool data is available)
|
|
309
|
+
- Optional next-step suggestion (only if useful)`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function getTeamContextPrompt(ctx: AppContext, requestedSiteId: number | null, defaultSiteId: number | null) {
|
|
313
|
+
const site = defaultSiteId ? ctx.sites?.find((s) => s.site_id === defaultSiteId) : null;
|
|
314
|
+
|
|
315
|
+
const teamId = ctx.team?.id ?? "unknown";
|
|
316
|
+
const teamExternalId = ctx.team?.external_id ?? 0;
|
|
317
|
+
|
|
318
|
+
const siteExternalId = site?.external_id ?? 0;
|
|
319
|
+
const siteDomain = site?.domain ?? null;
|
|
320
|
+
const availableSites = (ctx.sites ?? [])
|
|
321
|
+
.map((s) => `- ${s.site_id}: ${s.name || s.domain || `Site ${s.site_id}`}${s.domain ? ` (${s.domain})` : ""}`)
|
|
322
|
+
.join("\n") || "- none";
|
|
323
|
+
|
|
324
|
+
return `Current request context:
|
|
325
|
+
- team_id: ${teamId}
|
|
326
|
+
- team_external_id (postgres account_id): ${teamExternalId}
|
|
327
|
+
- requested_site_id_from_client: ${requestedSiteId ?? "none"}
|
|
328
|
+
- default_site_id_for_tools: ${defaultSiteId ?? "none"}
|
|
329
|
+
- site_external_id (postgres site_id): ${siteExternalId}
|
|
330
|
+
- site_domain: ${siteDomain ?? "unknown"}
|
|
331
|
+
|
|
332
|
+
Accessible sites for this user:
|
|
333
|
+
${availableSites}
|
|
334
|
+
|
|
335
|
+
Available tools:
|
|
336
|
+
- get_site_stats: summary metrics for a site (counts by event/country/device/referer).
|
|
337
|
+
- get_time_series: time series counts with granularity and optional grouping by event.
|
|
338
|
+
- get_metric_breakdown: top counts for events/countries/devices/referers/pages.
|
|
339
|
+
- get_nivo_chart_data: returns chart-ready data for Nivo charts when the user asks for a chart.
|
|
340
|
+
Use tools when you need real data. If a tool fails or data is missing, explain the limitation in plain language.
|
|
341
|
+
When a site-specific query is needed, default to default_site_id_for_tools and do not ask for site_id unless user explicitly asks for a different site.`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export const aiConfigRoute = route(
|
|
345
|
+
"/ai/config",
|
|
346
|
+
async ({ request }: RequestInfo<any, AppContext>) => {
|
|
347
|
+
if (request.method !== "GET") {
|
|
348
|
+
return new Response("Not Found.", { status: 404 });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const config = getAiConfig();
|
|
352
|
+
|
|
353
|
+
return new Response(
|
|
354
|
+
JSON.stringify({
|
|
355
|
+
configured: Boolean(config),
|
|
356
|
+
model: config?.model ?? "",
|
|
357
|
+
}),
|
|
358
|
+
{
|
|
359
|
+
status: 200,
|
|
360
|
+
headers: { "Content-Type": "application/json" },
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
export const aiTagSuggestRoute = route(
|
|
367
|
+
"/ai/site-tag-suggest",
|
|
368
|
+
async ({ request, ctx }: RequestInfo<any, AppContext>) => {
|
|
369
|
+
const requestId = crypto.randomUUID();
|
|
370
|
+
|
|
371
|
+
if (request.method !== "POST") {
|
|
372
|
+
return new Response(JSON.stringify({ error: "Method not allowed", requestId }), {
|
|
373
|
+
status: 405,
|
|
374
|
+
headers: { "Content-Type": "application/json" },
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const config = getAiConfig(ctx.team.id);
|
|
379
|
+
const aiConfigured = Boolean(config);
|
|
380
|
+
|
|
381
|
+
let body: unknown;
|
|
382
|
+
try {
|
|
383
|
+
body = await request.json();
|
|
384
|
+
} catch {
|
|
385
|
+
return new Response(JSON.stringify({ error: "Invalid JSON body", requestId }), {
|
|
386
|
+
status: 400,
|
|
387
|
+
headers: { "Content-Type": "application/json" },
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!isRecord(body)) {
|
|
392
|
+
return new Response(JSON.stringify({ error: "Invalid body", requestId }), {
|
|
393
|
+
status: 400,
|
|
394
|
+
headers: { "Content-Type": "application/json" },
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const urlInput = typeof body.url === "string" ? body.url : "";
|
|
399
|
+
const url = normalizeUrl(urlInput);
|
|
400
|
+
if (!url) {
|
|
401
|
+
return new Response(JSON.stringify({ error: "url must be a valid http(s) URL", requestId }), {
|
|
402
|
+
status: 400,
|
|
403
|
+
headers: { "Content-Type": "application/json" },
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (looksLikeIPAddress(url.hostname) || isPrivateNetworkHostname(url.hostname)) {
|
|
408
|
+
return new Response(JSON.stringify({ error: "url hostname is not allowed", requestId }), {
|
|
409
|
+
status: 400,
|
|
410
|
+
headers: { "Content-Type": "application/json" },
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const siteIdRaw = body.site_id;
|
|
415
|
+
const siteId = typeof siteIdRaw === "number" && Number.isInteger(siteIdRaw)
|
|
416
|
+
? siteIdRaw
|
|
417
|
+
: typeof siteIdRaw === "string" && siteIdRaw.trim() !== "" && !Number.isNaN(Number(siteIdRaw))
|
|
418
|
+
? parseInt(siteIdRaw, 10)
|
|
419
|
+
: null;
|
|
420
|
+
|
|
421
|
+
if (!siteId) {
|
|
422
|
+
return new Response(JSON.stringify({ error: "site_id is required", requestId }), {
|
|
423
|
+
status: 400,
|
|
424
|
+
headers: { "Content-Type": "application/json" },
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const site = getSiteFromContext(ctx, siteId);
|
|
429
|
+
if (!site || !site.uuid) {
|
|
430
|
+
return new Response(JSON.stringify({ error: "Site not found", requestId }), {
|
|
431
|
+
status: 404,
|
|
432
|
+
headers: { "Content-Type": "application/json" },
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!inferHostMatchesDomain(url.hostname, site.domain ?? null)) {
|
|
437
|
+
return new Response(
|
|
438
|
+
JSON.stringify({
|
|
439
|
+
error: "URL hostname does not match selected site domain",
|
|
440
|
+
requestId,
|
|
441
|
+
}),
|
|
442
|
+
{
|
|
443
|
+
status: 400,
|
|
444
|
+
headers: { "Content-Type": "application/json" },
|
|
445
|
+
},
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (url.protocol !== "https:" && !IS_DEV) {
|
|
450
|
+
return new Response(JSON.stringify({ error: "HTTPS URL required", requestId }), {
|
|
451
|
+
status: 400,
|
|
452
|
+
headers: { "Content-Type": "application/json" },
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let html = "";
|
|
457
|
+
try {
|
|
458
|
+
const controller = new AbortController();
|
|
459
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
460
|
+
const resp = await fetch(url.toString(), {
|
|
461
|
+
method: "GET",
|
|
462
|
+
headers: {
|
|
463
|
+
"User-Agent": "LytxBot/1.0 (+https://lytx.io)",
|
|
464
|
+
Accept: "text/html,application/xhtml+xml",
|
|
465
|
+
},
|
|
466
|
+
signal: controller.signal,
|
|
467
|
+
}).finally(() => clearTimeout(timeout));
|
|
468
|
+
|
|
469
|
+
if (!resp.ok) {
|
|
470
|
+
return new Response(
|
|
471
|
+
JSON.stringify({ error: `Failed to fetch URL (HTTP ${resp.status})`, requestId }),
|
|
472
|
+
{
|
|
473
|
+
status: 400,
|
|
474
|
+
headers: { "Content-Type": "application/json" },
|
|
475
|
+
},
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const contentType = resp.headers.get("content-type")?.toLowerCase() ?? "";
|
|
480
|
+
if (!contentType.includes("text/html")) {
|
|
481
|
+
return new Response(JSON.stringify({ error: "URL did not return HTML", requestId }), {
|
|
482
|
+
status: 400,
|
|
483
|
+
headers: { "Content-Type": "application/json" },
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
html = await resp.text();
|
|
488
|
+
} catch (error) {
|
|
489
|
+
console.error("site-tag-suggest fetch failed", { requestId, error });
|
|
490
|
+
return new Response(JSON.stringify({ error: "Failed to fetch URL", requestId }), {
|
|
491
|
+
status: 500,
|
|
492
|
+
headers: { "Content-Type": "application/json" },
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const tagId = site.tag_id_override ?? site.tag_id;
|
|
497
|
+
if (!tagId) {
|
|
498
|
+
return new Response(JSON.stringify({ error: "Site tag id is missing", requestId }), {
|
|
499
|
+
status: 400,
|
|
500
|
+
headers: { "Content-Type": "application/json" },
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const srcCandidates = extractScriptSrcs(html)
|
|
505
|
+
.map((src) => {
|
|
506
|
+
try {
|
|
507
|
+
return new URL(src, url).toString();
|
|
508
|
+
} catch {
|
|
509
|
+
return src;
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const tagFound = srcCandidates.some((src) => {
|
|
514
|
+
try {
|
|
515
|
+
const parsed = new URL(src);
|
|
516
|
+
if (!/lytx(\.v2)?\.js$/i.test(parsed.pathname)) return false;
|
|
517
|
+
return parsed.searchParams.get("account") === tagId;
|
|
518
|
+
} catch {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}) || new RegExp(`lytx(\\.v2)?\\.js\\?[^"']*account=${escapeRegExp(tagId)}`, "i").test(html);
|
|
522
|
+
|
|
523
|
+
let trackingOk: boolean | null = null;
|
|
524
|
+
try {
|
|
525
|
+
const stub = await getDurableDatabaseStub(site.uuid, site.site_id);
|
|
526
|
+
const health = await stub.healthCheck();
|
|
527
|
+
trackingOk = Boolean(health && (health.totalEvents ?? 0) > 0);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (IS_DEV) console.warn("site-tag-suggest durable health check failed", { requestId, error });
|
|
530
|
+
trackingOk = null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!aiConfigured) {
|
|
534
|
+
return new Response(
|
|
535
|
+
JSON.stringify({
|
|
536
|
+
requestId,
|
|
537
|
+
url: url.toString(),
|
|
538
|
+
site_id: site.site_id,
|
|
539
|
+
domain: site.domain,
|
|
540
|
+
tagId,
|
|
541
|
+
tagFound,
|
|
542
|
+
trackingOk,
|
|
543
|
+
aiConfigured,
|
|
544
|
+
suggestion:
|
|
545
|
+
"AI is not configured for this team. Configure the AI SDK environment variables to generate event recommendations.",
|
|
546
|
+
}),
|
|
547
|
+
{
|
|
548
|
+
status: 200,
|
|
549
|
+
headers: { "Content-Type": "application/json" },
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const aiConfig = config;
|
|
555
|
+
if (!aiConfig) {
|
|
556
|
+
return new Response(JSON.stringify({ error: "AI is not configured for this team", requestId }), {
|
|
557
|
+
status: 400,
|
|
558
|
+
headers: { "Content-Type": "application/json" },
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const dailyLimitState = await isTeamOverAiDailyLimit(ctx.team.id);
|
|
563
|
+
if (dailyLimitState.limited) {
|
|
564
|
+
await trackAiUsageSafely({
|
|
565
|
+
team_id: ctx.team.id,
|
|
566
|
+
user_id: ctx.session.user.id,
|
|
567
|
+
site_id: site.site_id,
|
|
568
|
+
request_id: requestId,
|
|
569
|
+
request_type: "site_tag_suggest",
|
|
570
|
+
provider: aiConfig.baseURL,
|
|
571
|
+
model: aiConfig.model,
|
|
572
|
+
status: "error",
|
|
573
|
+
error_code: "daily_limit_exceeded",
|
|
574
|
+
});
|
|
575
|
+
return new Response(
|
|
576
|
+
JSON.stringify({
|
|
577
|
+
error: "AI daily usage limit reached for this team. Try again tomorrow UTC.",
|
|
578
|
+
requestId,
|
|
579
|
+
used_tokens: dailyLimitState.usedTokens,
|
|
580
|
+
token_limit: dailyLimitState.limit,
|
|
581
|
+
}),
|
|
582
|
+
{
|
|
583
|
+
status: 429,
|
|
584
|
+
headers: { "Content-Type": "application/json" },
|
|
585
|
+
},
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const modelProvider = createOpenAICompatible({
|
|
590
|
+
baseURL: aiConfig.baseURL,
|
|
591
|
+
name: "team-model",
|
|
592
|
+
apiKey: aiConfig.apiKey,
|
|
593
|
+
includeUsage: true,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const prompt = `Analyze this page HTML and suggest DOM events to track.\n\nContext:\n- Selected site domain: ${site.domain ?? "unknown"}\n- Target URL: ${url.toString()}\n- Lytx tag id (account): ${tagId}\n- Tag script detected on page: ${tagFound}\n- Tracking events seen recently: ${trackingOk === null ? "unknown" : trackingOk}\n\nHTML (truncated):\n${truncateUtf8(html, 60_000)}`;
|
|
597
|
+
const aiStartedAt = Date.now();
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
const result = await generateText({
|
|
601
|
+
model: modelProvider.chatModel(aiConfig.model),
|
|
602
|
+
system: getSiteTagSystemPrompt(),
|
|
603
|
+
prompt,
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
const usage = extractTokenUsage((result as { usage?: unknown }).usage);
|
|
607
|
+
void trackAiUsageSafely({
|
|
608
|
+
team_id: ctx.team.id,
|
|
609
|
+
user_id: ctx.session.user.id,
|
|
610
|
+
site_id: site.site_id,
|
|
611
|
+
request_id: requestId,
|
|
612
|
+
request_type: "site_tag_suggest",
|
|
613
|
+
provider: aiConfig.baseURL,
|
|
614
|
+
model: aiConfig.model,
|
|
615
|
+
status: "success",
|
|
616
|
+
input_tokens: usage.inputTokens,
|
|
617
|
+
output_tokens: usage.outputTokens,
|
|
618
|
+
total_tokens: usage.totalTokens,
|
|
619
|
+
tool_calls: 0,
|
|
620
|
+
message_count: 1,
|
|
621
|
+
prompt_chars: prompt.length,
|
|
622
|
+
completion_chars: result.text.length,
|
|
623
|
+
duration_ms: Date.now() - aiStartedAt,
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
return new Response(
|
|
627
|
+
JSON.stringify({
|
|
628
|
+
requestId,
|
|
629
|
+
url: url.toString(),
|
|
630
|
+
site_id: site.site_id,
|
|
631
|
+
domain: site.domain,
|
|
632
|
+
tagId,
|
|
633
|
+
tagFound,
|
|
634
|
+
trackingOk,
|
|
635
|
+
aiConfigured,
|
|
636
|
+
suggestion: result.text,
|
|
637
|
+
}),
|
|
638
|
+
{
|
|
639
|
+
status: 200,
|
|
640
|
+
headers: { "Content-Type": "application/json" },
|
|
641
|
+
},
|
|
642
|
+
);
|
|
643
|
+
} catch (error) {
|
|
644
|
+
await trackAiUsageSafely({
|
|
645
|
+
team_id: ctx.team.id,
|
|
646
|
+
user_id: ctx.session.user.id,
|
|
647
|
+
site_id: site.site_id,
|
|
648
|
+
request_id: requestId,
|
|
649
|
+
request_type: "site_tag_suggest",
|
|
650
|
+
provider: aiConfig.baseURL,
|
|
651
|
+
model: aiConfig.model,
|
|
652
|
+
status: "error",
|
|
653
|
+
error_code: "ai_request_failed",
|
|
654
|
+
error_message: truncateUtf8(error instanceof Error ? error.message : "Unknown AI error", 500),
|
|
655
|
+
message_count: 1,
|
|
656
|
+
prompt_chars: prompt.length,
|
|
657
|
+
duration_ms: Date.now() - aiStartedAt,
|
|
658
|
+
});
|
|
659
|
+
console.error("site-tag-suggest ai error", { requestId, error });
|
|
660
|
+
return new Response(JSON.stringify({ error: "AI request failed", requestId }), {
|
|
661
|
+
status: 500,
|
|
662
|
+
headers: { "Content-Type": "application/json" },
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
export const aiChatRoute = route(
|
|
669
|
+
"/ai/chat",
|
|
670
|
+
async ({ request, ctx }: RequestInfo<any, AppContext>) => {
|
|
671
|
+
const requestId = crypto.randomUUID();
|
|
672
|
+
|
|
673
|
+
if (request.method !== "POST") {
|
|
674
|
+
return new Response(JSON.stringify({ error: "Method not allowed", requestId }), {
|
|
675
|
+
status: 405,
|
|
676
|
+
headers: { "Content-Type": "application/json" },
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const aiConfig = getAiConfig(ctx.team.id);
|
|
681
|
+
if (!aiConfig) {
|
|
682
|
+
return new Response(
|
|
683
|
+
JSON.stringify({ error: "AI is not configured for this team", requestId }),
|
|
684
|
+
{
|
|
685
|
+
status: 400,
|
|
686
|
+
headers: { "Content-Type": "application/json" },
|
|
687
|
+
},
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
let body: unknown;
|
|
692
|
+
try {
|
|
693
|
+
body = await request.json();
|
|
694
|
+
} catch {
|
|
695
|
+
return new Response(JSON.stringify({ error: "Invalid JSON body", requestId }), {
|
|
696
|
+
status: 400,
|
|
697
|
+
headers: { "Content-Type": "application/json" },
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (!isRecord(body)) {
|
|
702
|
+
return new Response(JSON.stringify({ error: "Invalid body", requestId }), {
|
|
703
|
+
status: 400,
|
|
704
|
+
headers: { "Content-Type": "application/json" },
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const messages = Array.isArray(body.messages) ? body.messages : null;
|
|
709
|
+
const siteId = parseSiteIdParam(body.site_id ?? null);
|
|
710
|
+
const debugStream = IS_DEV && (
|
|
711
|
+
body.debug_stream === true
|
|
712
|
+
|| request.headers.get("x-ai-debug-stream") === "1"
|
|
713
|
+
|| new URL(request.url).searchParams.get("debug_stream") === "1"
|
|
714
|
+
|| (env as any).AI_STREAM_DEBUG === "1"
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
if (!messages) {
|
|
718
|
+
return new Response(JSON.stringify({ error: "messages is required", requestId }), {
|
|
719
|
+
status: 400,
|
|
720
|
+
headers: { "Content-Type": "application/json" },
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const aiRequestStartedAt = Date.now();
|
|
725
|
+
const trimmedMessages = messages.slice(-20) as any;
|
|
726
|
+
const promptCharsEstimate = JSON.stringify(trimmedMessages).length;
|
|
727
|
+
const selectedSite = siteId ? getSiteFromContext(ctx, siteId) : null;
|
|
728
|
+
const fallbackSite = selectedSite ?? (ctx.sites?.[0] ?? null);
|
|
729
|
+
const defaultToolSiteId = fallbackSite?.site_id ?? null;
|
|
730
|
+
|
|
731
|
+
const dailyLimitState = await isTeamOverAiDailyLimit(ctx.team.id);
|
|
732
|
+
if (dailyLimitState.limited) {
|
|
733
|
+
await trackAiUsageSafely({
|
|
734
|
+
team_id: ctx.team.id,
|
|
735
|
+
user_id: ctx.session.user.id,
|
|
736
|
+
site_id: defaultToolSiteId,
|
|
737
|
+
request_id: requestId,
|
|
738
|
+
request_type: "chat",
|
|
739
|
+
provider: aiConfig.baseURL,
|
|
740
|
+
model: aiConfig.model,
|
|
741
|
+
status: "error",
|
|
742
|
+
error_code: "daily_limit_exceeded",
|
|
743
|
+
message_count: messages.length,
|
|
744
|
+
prompt_chars: promptCharsEstimate,
|
|
745
|
+
});
|
|
746
|
+
return new Response(
|
|
747
|
+
JSON.stringify({
|
|
748
|
+
error: "AI daily usage limit reached for this team. Try again tomorrow UTC.",
|
|
749
|
+
requestId,
|
|
750
|
+
used_tokens: dailyLimitState.usedTokens,
|
|
751
|
+
token_limit: dailyLimitState.limit,
|
|
752
|
+
}),
|
|
753
|
+
{
|
|
754
|
+
status: 429,
|
|
755
|
+
headers: { "Content-Type": "application/json" },
|
|
756
|
+
},
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const modelProvider = createOpenAICompatible({
|
|
761
|
+
baseURL: aiConfig.baseURL,
|
|
762
|
+
name: "team-model",
|
|
763
|
+
apiKey: aiConfig.apiKey,
|
|
764
|
+
includeUsage: true,
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const system = `${getSchemaPrompt()}\n\n${getTeamContextPrompt(ctx, siteId, defaultToolSiteId)}`;
|
|
768
|
+
|
|
769
|
+
const tools = {
|
|
770
|
+
get_site_stats: tool({
|
|
771
|
+
description: "Return summary metrics for a site over a date range.",
|
|
772
|
+
inputSchema: z.object({
|
|
773
|
+
site_id: z.number().optional().describe("Site id to query (optional, defaults to selected site)"),
|
|
774
|
+
startDate: z.string().optional().describe("ISO start date"),
|
|
775
|
+
endDate: z.string().optional().describe("ISO end date"),
|
|
776
|
+
}),
|
|
777
|
+
execute: async ({ site_id, startDate, endDate }) => {
|
|
778
|
+
const effectiveSiteId = site_id ?? defaultToolSiteId;
|
|
779
|
+
if (!effectiveSiteId) {
|
|
780
|
+
return { error: "No accessible site available" };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const site = getSiteFromContext(ctx, effectiveSiteId);
|
|
784
|
+
if (!site?.uuid) {
|
|
785
|
+
return { error: "Site not found" };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const stats = await getStatsFromDurableObject({
|
|
789
|
+
site_id: site.site_id,
|
|
790
|
+
site_uuid: site.uuid,
|
|
791
|
+
team_id: ctx.team?.id ?? 0,
|
|
792
|
+
date: {
|
|
793
|
+
start: parseDateParam(startDate) ?? undefined,
|
|
794
|
+
end: parseDateParam(endDate) ?? undefined,
|
|
795
|
+
},
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
if (!stats) {
|
|
799
|
+
return { error: "No stats available" };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return stats;
|
|
803
|
+
},
|
|
804
|
+
}),
|
|
805
|
+
get_time_series: tool({
|
|
806
|
+
description: "Return time series counts for a site.",
|
|
807
|
+
inputSchema: z.object({
|
|
808
|
+
site_id: z.number().optional().describe("Site id to query (optional, defaults to selected site)"),
|
|
809
|
+
startDate: z.string().optional().describe("ISO start date"),
|
|
810
|
+
endDate: z.string().optional().describe("ISO end date"),
|
|
811
|
+
granularity: z.enum(["hour", "day", "week", "month"]).optional(),
|
|
812
|
+
byEvent: z.boolean().optional(),
|
|
813
|
+
}),
|
|
814
|
+
execute: async ({ site_id, startDate, endDate, granularity, byEvent }) => {
|
|
815
|
+
const effectiveSiteId = site_id ?? defaultToolSiteId;
|
|
816
|
+
if (!effectiveSiteId) {
|
|
817
|
+
return { error: "No accessible site available" };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const site = getSiteFromContext(ctx, effectiveSiteId);
|
|
821
|
+
if (!site?.uuid) {
|
|
822
|
+
return { error: "Site not found" };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const result = await getTimeSeriesFromDurableObject({
|
|
826
|
+
site_id: site.site_id,
|
|
827
|
+
site_uuid: site.uuid,
|
|
828
|
+
team_id: ctx.team?.id ?? 0,
|
|
829
|
+
date: {
|
|
830
|
+
start: parseDateParam(startDate) ?? undefined,
|
|
831
|
+
end: parseDateParam(endDate) ?? undefined,
|
|
832
|
+
},
|
|
833
|
+
granularity,
|
|
834
|
+
byEvent: Boolean(byEvent),
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
if (!result) {
|
|
838
|
+
return { error: "No time series available" };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return result;
|
|
842
|
+
},
|
|
843
|
+
}),
|
|
844
|
+
get_metric_breakdown: tool({
|
|
845
|
+
description: "Return top metrics for events, countries, devices, referers, or pages.",
|
|
846
|
+
inputSchema: z.object({
|
|
847
|
+
site_id: z.number().optional().describe("Site id to query (optional, defaults to selected site)"),
|
|
848
|
+
metricType: z.enum(["events", "countries", "devices", "referers", "pages"]),
|
|
849
|
+
limit: z.number().optional().describe("Max number of rows"),
|
|
850
|
+
startDate: z.string().optional().describe("ISO start date"),
|
|
851
|
+
endDate: z.string().optional().describe("ISO end date"),
|
|
852
|
+
}),
|
|
853
|
+
execute: async ({ site_id, metricType, limit, startDate, endDate }) => {
|
|
854
|
+
const effectiveSiteId = site_id ?? defaultToolSiteId;
|
|
855
|
+
if (!effectiveSiteId) {
|
|
856
|
+
return { error: "No accessible site available" };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const site = getSiteFromContext(ctx, effectiveSiteId);
|
|
860
|
+
if (!site?.uuid) {
|
|
861
|
+
return { error: "Site not found" };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const result = await getMetricsFromDurableObject({
|
|
865
|
+
site_id: site.site_id,
|
|
866
|
+
site_uuid: site.uuid,
|
|
867
|
+
team_id: ctx.team?.id ?? 0,
|
|
868
|
+
metricType,
|
|
869
|
+
limit: clampLimit(limit, 10),
|
|
870
|
+
date: {
|
|
871
|
+
start: parseDateParam(startDate) ?? undefined,
|
|
872
|
+
end: parseDateParam(endDate) ?? undefined,
|
|
873
|
+
},
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
if (!result) {
|
|
877
|
+
return { error: "No metrics available" };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return result;
|
|
881
|
+
},
|
|
882
|
+
}),
|
|
883
|
+
get_nivo_chart_data: tool({
|
|
884
|
+
description: "Return chart-ready data for Nivo when user explicitly asks for a chart.",
|
|
885
|
+
inputSchema: z.object({
|
|
886
|
+
chartType: z.enum(["bar", "line", "pie"] as [NivoChartType, ...NivoChartType[]]),
|
|
887
|
+
site_id: z.number().optional().describe("Site id to query (optional, defaults to selected site)"),
|
|
888
|
+
metricType: z.enum(["events", "countries", "devices", "referers", "pages"]).optional(),
|
|
889
|
+
startDate: z.string().optional().describe("ISO start date"),
|
|
890
|
+
endDate: z.string().optional().describe("ISO end date"),
|
|
891
|
+
granularity: z.enum(["hour", "day", "week", "month"]).optional(),
|
|
892
|
+
limit: z.number().optional().describe("Max number of rows"),
|
|
893
|
+
title: z.string().optional().describe("Optional chart title"),
|
|
894
|
+
}),
|
|
895
|
+
execute: async ({ chartType, site_id, metricType, startDate, endDate, granularity, limit, title }) => {
|
|
896
|
+
const effectiveSiteId = site_id ?? defaultToolSiteId;
|
|
897
|
+
if (!effectiveSiteId) {
|
|
898
|
+
return { error: "No accessible site available" };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const site = getSiteFromContext(ctx, effectiveSiteId);
|
|
902
|
+
if (!site?.uuid) {
|
|
903
|
+
return { error: "Site not found" };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const defaultMetricType = metricType ?? "events";
|
|
907
|
+
|
|
908
|
+
if (chartType === "line") {
|
|
909
|
+
const timeSeries = await getTimeSeriesFromDurableObject({
|
|
910
|
+
site_id: site.site_id,
|
|
911
|
+
site_uuid: site.uuid,
|
|
912
|
+
team_id: ctx.team?.id ?? 0,
|
|
913
|
+
date: {
|
|
914
|
+
start: parseDateParam(startDate) ?? undefined,
|
|
915
|
+
end: parseDateParam(endDate) ?? undefined,
|
|
916
|
+
},
|
|
917
|
+
granularity,
|
|
918
|
+
byEvent: false,
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
if (!timeSeries) {
|
|
922
|
+
return { error: "No time series available" };
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return {
|
|
926
|
+
kind: "nivo-chart",
|
|
927
|
+
chartType,
|
|
928
|
+
title: title || `${site.name || `Site ${site.site_id}`} trend`,
|
|
929
|
+
metricType: defaultMetricType,
|
|
930
|
+
siteId: site.site_id,
|
|
931
|
+
dateRange: timeSeries.dateRange,
|
|
932
|
+
points: timeSeries.data.map((item) => ({ x: item.date, y: item.count })),
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const metricResult = await getMetricsFromDurableObject({
|
|
937
|
+
site_id: site.site_id,
|
|
938
|
+
site_uuid: site.uuid,
|
|
939
|
+
team_id: ctx.team?.id ?? 0,
|
|
940
|
+
metricType: defaultMetricType,
|
|
941
|
+
limit: clampLimit(limit, 10),
|
|
942
|
+
date: {
|
|
943
|
+
start: parseDateParam(startDate) ?? undefined,
|
|
944
|
+
end: parseDateParam(endDate) ?? undefined,
|
|
945
|
+
},
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
if (!metricResult) {
|
|
949
|
+
return { error: "No metrics available" };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
return {
|
|
953
|
+
kind: "nivo-chart",
|
|
954
|
+
chartType,
|
|
955
|
+
title: title || `${site.name || `Site ${site.site_id}`} ${defaultMetricType}`,
|
|
956
|
+
metricType: defaultMetricType,
|
|
957
|
+
siteId: site.site_id,
|
|
958
|
+
dateRange: metricResult.dateRange,
|
|
959
|
+
points: metricResult.data.map((item) => ({
|
|
960
|
+
x: item.label || "Unknown",
|
|
961
|
+
y: item.count,
|
|
962
|
+
})),
|
|
963
|
+
};
|
|
964
|
+
},
|
|
965
|
+
}),
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
try {
|
|
969
|
+
const modelMessages = await convertToModelMessages(trimmedMessages, {
|
|
970
|
+
tools,
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
let resolveStreamSummary!: (summary: StreamCompletionSummary) => void;
|
|
974
|
+
const streamSummaryPromise = new Promise<StreamCompletionSummary>((resolve) => {
|
|
975
|
+
resolveStreamSummary = resolve;
|
|
976
|
+
});
|
|
977
|
+
let hasResolvedStreamSummary = false;
|
|
978
|
+
const settleStreamSummary = (summary: StreamCompletionSummary) => {
|
|
979
|
+
if (hasResolvedStreamSummary) return;
|
|
980
|
+
hasResolvedStreamSummary = true;
|
|
981
|
+
resolveStreamSummary(summary);
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
const result = streamText({
|
|
985
|
+
model: modelProvider.chatModel(aiConfig.model),
|
|
986
|
+
system,
|
|
987
|
+
messages: modelMessages,
|
|
988
|
+
tools,
|
|
989
|
+
// stopWhen can be re-enabled after stream debugging if needed.
|
|
990
|
+
onChunk: ({ chunk }) => {
|
|
991
|
+
if (!debugStream) return;
|
|
992
|
+
|
|
993
|
+
if (chunk.type === "text-delta") {
|
|
994
|
+
console.log("[AI stream chunk]", {
|
|
995
|
+
requestId,
|
|
996
|
+
type: chunk.type,
|
|
997
|
+
text: previewText(chunk.text, 80),
|
|
998
|
+
});
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (chunk.type === "tool-input-start") {
|
|
1003
|
+
console.log("[AI stream chunk]", {
|
|
1004
|
+
requestId,
|
|
1005
|
+
type: chunk.type,
|
|
1006
|
+
toolName: chunk.toolName,
|
|
1007
|
+
});
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (chunk.type === "tool-call") {
|
|
1012
|
+
console.log("[AI stream chunk]", {
|
|
1013
|
+
requestId,
|
|
1014
|
+
type: chunk.type,
|
|
1015
|
+
toolName: chunk.toolName,
|
|
1016
|
+
});
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (chunk.type === "tool-result") {
|
|
1021
|
+
console.log("[AI stream chunk]", {
|
|
1022
|
+
requestId,
|
|
1023
|
+
type: chunk.type,
|
|
1024
|
+
toolName: chunk.toolName,
|
|
1025
|
+
});
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
},
|
|
1030
|
+
onStepFinish: (stepResult) => {
|
|
1031
|
+
if (!debugStream) return;
|
|
1032
|
+
|
|
1033
|
+
console.log("[AI step finish]", {
|
|
1034
|
+
requestId,
|
|
1035
|
+
finishReason: stepResult.finishReason,
|
|
1036
|
+
toolCalls: stepResult.toolCalls.map((call) => call.toolName),
|
|
1037
|
+
toolResults: stepResult.toolResults.map((result) => result.toolName),
|
|
1038
|
+
textPreview: previewText(stepResult.text),
|
|
1039
|
+
usage: stepResult.usage,
|
|
1040
|
+
});
|
|
1041
|
+
},
|
|
1042
|
+
onFinish: ({ finishReason, totalUsage, steps }) => {
|
|
1043
|
+
const toolCallCount = steps.reduce((count, stepResult) => {
|
|
1044
|
+
const stepToolCalls = Array.isArray(stepResult.toolCalls) ? stepResult.toolCalls.length : 0;
|
|
1045
|
+
return count + stepToolCalls;
|
|
1046
|
+
}, 0);
|
|
1047
|
+
const completionChars = steps.reduce((count, stepResult) => {
|
|
1048
|
+
return count + (stepResult.text?.length ?? 0);
|
|
1049
|
+
}, 0);
|
|
1050
|
+
|
|
1051
|
+
settleStreamSummary({
|
|
1052
|
+
finishReason,
|
|
1053
|
+
toolCallCount,
|
|
1054
|
+
completionChars,
|
|
1055
|
+
stepCount: steps.length,
|
|
1056
|
+
usageFromOnFinish: totalUsage,
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
if (!debugStream) return;
|
|
1060
|
+
|
|
1061
|
+
console.log("[AI stream finish]", {
|
|
1062
|
+
requestId,
|
|
1063
|
+
finishReason,
|
|
1064
|
+
stepCount: steps.length,
|
|
1065
|
+
totalUsage,
|
|
1066
|
+
});
|
|
1067
|
+
},
|
|
1068
|
+
onError: ({ error: streamError }) => {
|
|
1069
|
+
settleStreamSummary({
|
|
1070
|
+
finishReason: "error",
|
|
1071
|
+
toolCallCount: 0,
|
|
1072
|
+
completionChars: 0,
|
|
1073
|
+
stepCount: 0,
|
|
1074
|
+
usageFromOnFinish: null,
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
if (!debugStream) return;
|
|
1078
|
+
console.error("[AI stream error]", { requestId, error: streamError });
|
|
1079
|
+
},
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
const usagePromise = Promise.resolve(result.usage).then(
|
|
1083
|
+
(usage) => usage,
|
|
1084
|
+
(error: unknown) => {
|
|
1085
|
+
if (debugStream) {
|
|
1086
|
+
console.warn("[AI usage promise failed]", { requestId, error });
|
|
1087
|
+
}
|
|
1088
|
+
return null;
|
|
1089
|
+
},
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
void Promise.all([
|
|
1093
|
+
usagePromise,
|
|
1094
|
+
streamSummaryPromise,
|
|
1095
|
+
]).then(([usageFromPromise, streamSummary]) => {
|
|
1096
|
+
const usageFromStream = extractTokenUsage(streamSummary.usageFromOnFinish);
|
|
1097
|
+
const usageFromResultPromise = extractTokenUsage(usageFromPromise);
|
|
1098
|
+
const usage = usageFromResultPromise.totalTokens !== null
|
|
1099
|
+
? usageFromResultPromise
|
|
1100
|
+
: usageFromStream;
|
|
1101
|
+
|
|
1102
|
+
void trackAiUsageSafely({
|
|
1103
|
+
team_id: ctx.team.id,
|
|
1104
|
+
user_id: ctx.session.user.id,
|
|
1105
|
+
site_id: defaultToolSiteId,
|
|
1106
|
+
request_id: requestId,
|
|
1107
|
+
request_type: "chat",
|
|
1108
|
+
provider: aiConfig.baseURL,
|
|
1109
|
+
model: aiConfig.model,
|
|
1110
|
+
status: streamSummary.finishReason === "error" ? "error" : "success",
|
|
1111
|
+
error_code: streamSummary.finishReason === "error" ? "stream_finish_error" : null,
|
|
1112
|
+
input_tokens: usage.inputTokens,
|
|
1113
|
+
output_tokens: usage.outputTokens,
|
|
1114
|
+
total_tokens: usage.totalTokens,
|
|
1115
|
+
tool_calls: streamSummary.toolCallCount,
|
|
1116
|
+
message_count: messages.length,
|
|
1117
|
+
prompt_chars: promptCharsEstimate,
|
|
1118
|
+
completion_chars: streamSummary.completionChars,
|
|
1119
|
+
duration_ms: Date.now() - aiRequestStartedAt,
|
|
1120
|
+
});
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
return result.toUIMessageStreamResponse({
|
|
1124
|
+
headers: {
|
|
1125
|
+
"x-request-id": requestId,
|
|
1126
|
+
"Cache-Control": "no-cache, no-transform",
|
|
1127
|
+
"Content-Encoding": "identity",
|
|
1128
|
+
},
|
|
1129
|
+
});
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
await trackAiUsageSafely({
|
|
1132
|
+
team_id: ctx.team.id,
|
|
1133
|
+
user_id: ctx.session.user.id,
|
|
1134
|
+
site_id: defaultToolSiteId,
|
|
1135
|
+
request_id: requestId,
|
|
1136
|
+
request_type: "chat",
|
|
1137
|
+
provider: aiConfig.baseURL,
|
|
1138
|
+
model: aiConfig.model,
|
|
1139
|
+
status: "error",
|
|
1140
|
+
error_code: "chat_request_failed",
|
|
1141
|
+
error_message: truncateUtf8(error instanceof Error ? error.message : "Unknown AI chat error", 500),
|
|
1142
|
+
message_count: messages.length,
|
|
1143
|
+
prompt_chars: promptCharsEstimate,
|
|
1144
|
+
duration_ms: Date.now() - aiRequestStartedAt,
|
|
1145
|
+
});
|
|
1146
|
+
console.error("AI chat error", { requestId, error });
|
|
1147
|
+
return new Response(
|
|
1148
|
+
JSON.stringify({ error: "AI chat failed", requestId }),
|
|
1149
|
+
{
|
|
1150
|
+
status: 500,
|
|
1151
|
+
headers: { "Content-Type": "application/json" },
|
|
1152
|
+
},
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
},
|
|
1156
|
+
);
|