Skip to content

Commit 8b998e6

Browse files
authored
feat(agent): add manual analytics query invocation
1 parent 83a7a84 commit 8b998e6

File tree

5 files changed

+367
-9
lines changed

5 files changed

+367
-9
lines changed

apps/api/src/routes/agent.ts

Lines changed: 174 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { Elysia, t } from "elysia";
44
import { createReflectionAgent, createTriageAgent } from "../ai/agents";
55
import { buildAppContext } from "../ai/config/context";
66
import { record, setAttributes } from "../lib/tracing";
7-
import { validateWebsite } from "../lib/website-utils";
8-
import { QueryBuilders } from "../query/builders";
7+
import { getWebsiteDomain, validateWebsite } from "../lib/website-utils";
8+
import { executeQuery, QueryBuilders } from "../query/builders";
9+
import type { QueryRequest } from "../query/types";
910

1011
const AgentRequestSchema = t.Object({
1112
websiteId: t.String(),
@@ -30,6 +31,58 @@ const AgentRequestSchema = t.Object({
3031
),
3132
});
3233

34+
/**
35+
* Schema for manual tool invocation requests.
36+
* Allows users to directly invoke analytics queries without going through the AI agent.
37+
*/
38+
const InvokeRequestSchema = t.Object({
39+
websiteId: t.String(),
40+
tool: t.String({
41+
description: "The tool/query type to invoke",
42+
}),
43+
params: t.Object({
44+
from: t.String({ description: "Start date in ISO format (e.g., 2024-01-01)" }),
45+
to: t.String({ description: "End date in ISO format (e.g., 2024-01-31)" }),
46+
timeUnit: t.Optional(
47+
t.Union([
48+
t.Literal("minute"),
49+
t.Literal("hour"),
50+
t.Literal("day"),
51+
t.Literal("week"),
52+
t.Literal("month"),
53+
])
54+
),
55+
filters: t.Optional(
56+
t.Array(
57+
t.Object({
58+
field: t.String(),
59+
op: t.Union([
60+
t.Literal("eq"),
61+
t.Literal("ne"),
62+
t.Literal("contains"),
63+
t.Literal("not_contains"),
64+
t.Literal("starts_with"),
65+
t.Literal("in"),
66+
t.Literal("not_in"),
67+
]),
68+
value: t.Union([
69+
t.String(),
70+
t.Number(),
71+
t.Array(t.Union([t.String(), t.Number()])),
72+
]),
73+
target: t.Optional(t.String()),
74+
having: t.Optional(t.Boolean()),
75+
})
76+
)
77+
),
78+
groupBy: t.Optional(t.Array(t.String())),
79+
orderBy: t.Optional(t.String()),
80+
limit: t.Optional(t.Number({ minimum: 1, maximum: 1000 })),
81+
offset: t.Optional(t.Number({ minimum: 0 })),
82+
}),
83+
timezone: t.Optional(t.String()),
84+
});
85+
3386
type UIMessage = {
3487
id: string;
3588
role: "user" | "assistant";
@@ -205,4 +258,122 @@ export const agent = new Elysia({ prefix: "/v1/agent" })
205258
});
206259
},
207260
{ body: AgentRequestSchema, idleTimeout: 60_000 }
208-
);
261+
)
262+
.post(
263+
"/invoke",
264+
async function agentInvoke({ body, user, request }) {
265+
return record("agentInvoke", async () => {
266+
setAttributes({
267+
"agent.website_id": body.websiteId,
268+
"agent.user_id": user?.id ?? "unknown",
269+
"agent.tool": body.tool,
270+
});
271+
272+
try {
273+
const websiteValidation = await validateWebsite(body.websiteId);
274+
if (!(websiteValidation.success && websiteValidation.website)) {
275+
return {
276+
success: false,
277+
error: websiteValidation.error ?? "Website not found",
278+
};
279+
}
280+
281+
const { website } = websiteValidation;
282+
283+
let authorized = website.isPublic;
284+
if (!authorized) {
285+
if (website.organizationId) {
286+
const { success } = await websitesApi.hasPermission({
287+
headers: request.headers,
288+
body: { permissions: { website: ["read"] } },
289+
});
290+
authorized = success;
291+
} else {
292+
authorized = website.userId === user?.id;
293+
}
294+
}
295+
296+
if (!authorized) {
297+
return {
298+
success: false,
299+
error: "Unauthorized",
300+
};
301+
}
302+
303+
// Validate the tool exists
304+
const availableTools = Object.keys(QueryBuilders);
305+
if (!availableTools.includes(body.tool)) {
306+
return {
307+
success: false,
308+
error: `Unknown tool: ${body.tool}. Available tools: ${availableTools.join(", ")}`,
309+
availableTools,
310+
};
311+
}
312+
313+
// Get website domain for the query
314+
const websiteDomain = await getWebsiteDomain(body.websiteId);
315+
316+
// Build and execute the query
317+
const queryRequest: QueryRequest = {
318+
projectId: body.websiteId,
319+
type: body.tool,
320+
from: body.params.from,
321+
to: body.params.to,
322+
timeUnit: body.params.timeUnit,
323+
filters: body.params.filters,
324+
groupBy: body.params.groupBy,
325+
orderBy: body.params.orderBy,
326+
limit: body.params.limit,
327+
offset: body.params.offset,
328+
timezone: body.timezone ?? "UTC",
329+
};
330+
331+
const startTime = Date.now();
332+
const data = await executeQuery(
333+
queryRequest,
334+
websiteDomain,
335+
queryRequest.timezone
336+
);
337+
const executionTime = Date.now() - startTime;
338+
339+
return {
340+
success: true,
341+
tool: body.tool,
342+
data,
343+
meta: {
344+
rowCount: data.length,
345+
executionTime,
346+
timezone: queryRequest.timezone,
347+
from: body.params.from,
348+
to: body.params.to,
349+
},
350+
};
351+
} catch (error) {
352+
console.error("Agent invoke error:", error);
353+
return {
354+
success: false,
355+
error: error instanceof Error ? error.message : "Unknown error",
356+
};
357+
}
358+
});
359+
},
360+
{ body: InvokeRequestSchema }
361+
)
362+
.get("/tools", async function listTools({ user }) {
363+
if (!user?.id) {
364+
return {
365+
success: false,
366+
error: "Authentication required",
367+
};
368+
}
369+
370+
const tools = Object.keys(QueryBuilders).map((key) => ({
371+
name: key,
372+
description: `Execute ${key} analytics query`,
373+
}));
374+
375+
return {
376+
success: true,
377+
tools,
378+
};
379+
});

apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-atoms.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export type AgentCommand = {
2020
toolName: string;
2121
toolParams?: Record<string, unknown>;
2222
keywords: string[];
23+
/** Whether this command should be invoked directly without AI */
24+
manualInvoke?: boolean;
2325
};
2426

2527
export const agentMessagesAtom = atom<UIMessage[]>([]);

apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-commands.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,61 @@ export const AGENT_COMMANDS: AgentCommand[] = [
121121
toolName: "compare_periods",
122122
keywords: ["compare", "periods", "before", "after", "change"],
123123
},
124+
// Manual Invoke - Direct query execution without AI
125+
{
126+
id: "invoke-summary",
127+
command: "/invoke",
128+
title: "Invoke summary metrics",
129+
description: "Directly execute summary analytics (pageviews, visitors, sessions)",
130+
toolName: "summary_metrics",
131+
keywords: ["invoke", "summary", "metrics", "overview", "query", "direct", "manual"],
132+
manualInvoke: true,
133+
},
134+
{
135+
id: "invoke-pages",
136+
command: "/invoke",
137+
title: "Invoke top pages",
138+
description: "Directly execute top pages query",
139+
toolName: "top_pages",
140+
keywords: ["invoke", "pages", "top", "query", "direct", "manual"],
141+
manualInvoke: true,
142+
},
143+
{
144+
id: "invoke-referrers",
145+
command: "/invoke",
146+
title: "Invoke top referrers",
147+
description: "Directly execute referrers query",
148+
toolName: "top_referrers",
149+
keywords: ["invoke", "referrers", "sources", "query", "direct", "manual"],
150+
manualInvoke: true,
151+
},
152+
{
153+
id: "invoke-traffic-sources",
154+
command: "/invoke",
155+
title: "Invoke traffic sources",
156+
description: "Directly execute traffic sources breakdown query",
157+
toolName: "traffic_sources",
158+
keywords: ["invoke", "traffic", "sources", "query", "direct", "manual"],
159+
manualInvoke: true,
160+
},
161+
{
162+
id: "invoke-utm-sources",
163+
command: "/invoke",
164+
title: "Invoke UTM sources",
165+
description: "Directly execute UTM sources breakdown query",
166+
toolName: "utm_sources",
167+
keywords: ["invoke", "utm", "sources", "campaigns", "query", "direct", "manual"],
168+
manualInvoke: true,
169+
},
170+
{
171+
id: "invoke-utm-campaigns",
172+
command: "/invoke",
173+
title: "Invoke UTM campaigns",
174+
description: "Directly execute UTM campaigns breakdown query",
175+
toolName: "utm_campaigns",
176+
keywords: ["invoke", "utm", "campaigns", "marketing", "query", "direct", "manual"],
177+
manualInvoke: true,
178+
},
124179
];
125180

126181
export function filterCommands(query: string): AgentCommand[] {

apps/dashboard/app/(main)/websites/[id]/agent/_components/hooks/use-agent-commands.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import {
99
showCommandsAtom,
1010
} from "../agent-atoms";
1111
import { filterCommands } from "../agent-commands";
12+
import { useManualInvoke } from "./use-manual-invoke";
1213

1314
export function useAgentCommands() {
1415
const [input, setInput] = useAtom(agentInputAtom);
1516
const [showCommands, setShowCommands] = useAtom(showCommandsAtom);
1617
const [commandQuery, setCommandQuery] = useAtom(commandQueryAtom);
1718
const [selectedIndex, setSelectedIndex] = useAtom(selectedCommandIndexAtom);
1819
const { sendMessage } = useChatActions();
20+
const { invokeWithDefaults, isLoading: isInvoking } = useManualInvoke();
1921

2022
const filteredCommands = useMemo(
2123
() => filterCommands(commandQuery),
@@ -43,17 +45,40 @@ export function useAgentCommands() {
4345
);
4446

4547
const executeCommand = useCallback(
46-
(command: AgentCommand) => {
47-
sendMessage({
48-
text: command.title,
49-
metadata: { toolChoice: command.toolName },
50-
});
48+
async (command: AgentCommand) => {
49+
// Handle manual invoke commands differently
50+
if (command.manualInvoke) {
51+
try {
52+
const result = await invokeWithDefaults(command.toolName);
53+
// Send the result as a message to display in chat
54+
sendMessage({
55+
text: `Executed ${command.title}. Results: ${result.meta?.rowCount ?? 0} rows in ${result.meta?.executionTime ?? 0}ms`,
56+
metadata: {
57+
manualInvoke: true,
58+
toolName: command.toolName,
59+
result: result.data,
60+
meta: result.meta,
61+
},
62+
});
63+
} catch (error) {
64+
sendMessage({
65+
text: `Failed to execute ${command.title}: ${error instanceof Error ? error.message : "Unknown error"}`,
66+
metadata: { error: true },
67+
});
68+
}
69+
} else {
70+
// Regular AI-assisted command
71+
sendMessage({
72+
text: command.title,
73+
metadata: { toolChoice: command.toolName },
74+
});
75+
}
5176
setInput("");
5277
setShowCommands(false);
5378
setCommandQuery("");
5479
setSelectedIndex(0);
5580
},
56-
[sendMessage, setCommandQuery, setInput, setSelectedIndex, setShowCommands]
81+
[invokeWithDefaults, sendMessage, setCommandQuery, setInput, setSelectedIndex, setShowCommands]
5782
);
5883

5984
const navigateUp = useCallback(() => {
@@ -128,5 +153,6 @@ export function useAgentCommands() {
128153
navigateUp,
129154
navigateDown,
130155
selectCurrent,
156+
isInvoking,
131157
};
132158
}

0 commit comments

Comments
 (0)