Skip to content

Commit 478f180

Browse files
committed
Generate a title using AI
1 parent 4f1e0a7 commit 478f180

File tree

9 files changed

+255
-17
lines changed

9 files changed

+255
-17
lines changed

apps/webapp/app/components/primitives/charts/Card.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export const Card = ({ children, className }: { children: ReactNode; className?:
66
return (
77
<div
88
className={cn(
9-
"flex flex-col rounded-lg border border-grid-bright bg-background-bright pb-2 pt-4",
9+
"flex flex-col rounded-lg border border-grid-bright bg-background-bright pb-1.5 pt-3",
1010
className
1111
)}
1212
>
@@ -17,7 +17,7 @@ export const Card = ({ children, className }: { children: ReactNode; className?:
1717

1818
const CardHeader = ({ children }: { children: ReactNode }) => {
1919
return (
20-
<Header3 className="mb-4 flex items-center justify-between gap-2 px-4">{children}</Header3>
20+
<Header3 className="mb-3 flex items-center justify-between gap-2 px-3">{children}</Header3>
2121
);
2222
};
2323

apps/webapp/app/env.server.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,6 @@ const EnvironmentSchema = z
521521
PROD_USAGE_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
522522

523523
CENTS_PER_RUN: z.coerce.number().default(0),
524-
CENTS_PER_QUERY_BYTE_SECOND: z.coerce.number().default(0),
525524

526525
EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"),
527526
RESOURCE_MONITOR_ENABLED: z.string().default("0"),

apps/webapp/app/presenters/v3/QueryPresenter.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export type QueryHistoryItem = {
88
scope: QueryScope;
99
createdAt: Date;
1010
userName: string | null;
11+
/** AI-generated title summarizing the query */
12+
title: string | null;
1113
/** Time filter settings */
1214
filterPeriod: string | null;
1315
filterFrom: Date | null;
@@ -24,6 +26,7 @@ export class QueryPresenter extends BasePresenter {
2426
id: true,
2527
query: true,
2628
scope: true,
29+
title: true,
2730
createdAt: true,
2831
filterPeriod: true,
2932
filterFrom: true,
@@ -43,6 +46,7 @@ export class QueryPresenter extends BasePresenter {
4346
scope: q.scope.toLowerCase() as QueryScope,
4447
createdAt: q.createdAt,
4548
userName: q.user?.displayName ?? q.user?.name ?? null,
49+
title: q.title,
4650
filterPeriod: q.filterPeriod,
4751
filterFrom: q.filterFrom,
4852
filterTo: q.filterTo,

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { ArrowDownTrayIcon, ArrowsPointingInIcon, ArrowsPointingOutIcon, ArrowTrendingUpIcon, ClipboardIcon } from "@heroicons/react/20/solid";
1+
import { ArrowDownTrayIcon, ArrowsPointingInIcon, ArrowsPointingOutIcon, ArrowTrendingUpIcon, ClipboardIcon, TableCellsIcon } from "@heroicons/react/20/solid";
2+
import { useFetcher } from "@remix-run/react";
3+
import type { action as titleAction } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title";
24
import type { OutputColumnMetadata, WhereClauseFallback } from "@internal/clickhouse";
35
import {
46
redirect,
@@ -374,12 +376,23 @@ const QueryEditorForm = forwardRef<
374376
history: QueryHistoryItem[];
375377
fetcher: ReturnType<typeof useTypedFetcher<typeof action>>;
376378
isAdmin: boolean;
379+
onQuerySubmit?: () => void;
380+
onHistorySelected?: (item: QueryHistoryItem) => void;
377381
}
378-
>(function QueryEditorForm({ defaultQuery, defaultScope, defaultTimeFilter, history, fetcher, isAdmin }, ref) {
382+
>(function QueryEditorForm({ defaultQuery, defaultScope, defaultTimeFilter, history, fetcher, isAdmin, onQuerySubmit, onHistorySelected }, ref) {
379383
const isLoading = fetcher.state === "submitting" || fetcher.state === "loading";
380384
const [query, setQuery] = useState(defaultQuery);
381385
const [scope, setScope] = useState<QueryScope>(defaultScope);
382386
const formRef = useRef<HTMLFormElement>(null);
387+
const prevFetcherState = useRef(fetcher.state);
388+
389+
// Notify parent when query is submitted (for title generation)
390+
useEffect(() => {
391+
if (prevFetcherState.current !== "submitting" && fetcher.state === "submitting") {
392+
onQuerySubmit?.();
393+
}
394+
prevFetcherState.current = fetcher.state;
395+
}, [fetcher.state, onQuerySubmit]);
383396

384397
// Get time filter values - initialize from props (which may come from history)
385398
const [period, setPeriod] = useState<string | undefined>(defaultTimeFilter?.period);
@@ -414,7 +427,9 @@ const QueryEditorForm = forwardRef<
414427
setPeriod(item.filterPeriod ?? undefined);
415428
setFrom(item.filterFrom ? toISOString(item.filterFrom) : undefined);
416429
setTo(item.filterTo ? toISOString(item.filterTo) : undefined);
417-
}, []);
430+
// Notify parent about history selection (for title)
431+
onHistorySelected?.(item);
432+
}, [onHistorySelected]);
418433

419434
return (
420435
<div className="flex h-full flex-col gap-2 bg-charcoal-900 pb-2">
@@ -514,6 +529,10 @@ export default function Page() {
514529
const results = fetcher.data;
515530
const { replace: replaceSearchParams } = useSearchParams();
516531

532+
const organization = useOrganization();
533+
const project = useProject();
534+
const environment = useEnvironment();
535+
517536
// Use most recent history item if available, otherwise fall back to defaults
518537
const initialQuery = history.length > 0 ? history[0].query : defaultQuery;
519538
const initialScope: QueryScope = history.length > 0 ? history[0].scope : "environment";
@@ -533,6 +552,44 @@ export default function Page() {
533552
const [sidebarTab, setSidebarTab] = useState<string>("ai");
534553
const [aiFixRequest, setAiFixRequest] = useState<{ prompt: string; key: number } | null>(null);
535554

555+
// Title generation state
556+
const titleFetcher = useFetcher<typeof titleAction>();
557+
const isTitleLoading = titleFetcher.state !== "idle";
558+
const generatedTitle = titleFetcher.data?.title;
559+
const [historyTitle, setHistoryTitle] = useState<string | null>(
560+
history.length > 0 ? history[0].title ?? null : null
561+
);
562+
563+
// Effective title: history title takes precedence, then generated
564+
const queryTitle = historyTitle ?? generatedTitle ?? null;
565+
566+
// Track whether we should generate a title for the current results
567+
const [shouldGenerateTitle, setShouldGenerateTitle] = useState(false);
568+
569+
// Trigger title generation when query succeeds (only for new queries, not history)
570+
useEffect(() => {
571+
if (
572+
results?.rows &&
573+
!results.error &&
574+
shouldGenerateTitle &&
575+
!historyTitle &&
576+
titleFetcher.state === "idle"
577+
) {
578+
const currentQuery = editorRef.current?.getQuery();
579+
if (currentQuery) {
580+
titleFetcher.submit(
581+
{ query: currentQuery },
582+
{
583+
method: "POST",
584+
action: `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-title`,
585+
encType: "application/json",
586+
}
587+
);
588+
setShouldGenerateTitle(false);
589+
}
590+
}
591+
}, [results, shouldGenerateTitle, historyTitle, titleFetcher, organization.slug, project.slug, environment.slug]);
592+
536593
const handleTryFixError = useCallback((errorMessage: string) => {
537594
setSidebarTab("ai");
538595
setAiFixRequest((prev) => ({
@@ -575,6 +632,18 @@ export default function Page() {
575632
setChartConfig(config);
576633
}, []);
577634

635+
// Handle query submission - prepare for title generation
636+
const handleQuerySubmit = useCallback(() => {
637+
setHistoryTitle(null); // Clear history title when running a new query
638+
setShouldGenerateTitle(true); // Enable title generation for new results
639+
}, []);
640+
641+
// Handle history selection - use existing title if available
642+
const handleHistorySelected = useCallback((item: QueryHistoryItem) => {
643+
setHistoryTitle(item.title ?? null);
644+
setShouldGenerateTitle(false); // Don't generate title for history items
645+
}, []);
646+
578647
return (
579648
<PageContainer>
580649
<NavBar>
@@ -594,6 +663,8 @@ export default function Page() {
594663
history={history}
595664
fetcher={fetcher}
596665
isAdmin={isAdmin}
666+
onQuerySubmit={handleQuerySubmit}
667+
onHistorySelected={handleHistorySelected}
597668
/>
598669
</ResizablePanel>
599670
<ResizableHandle id="query-editor-handle" />
@@ -701,7 +772,19 @@ export default function Page() {
701772
</Callout>
702773
)}
703774
<div className="h-full bg-charcoal-900 p-2">
704-
<Card className="h-full overflow-hidden p-0">
775+
<Card className="h-full overflow-hidden px-0 pb-0">
776+
<Card.Header>
777+
<div className="flex items-center gap-1.5">
778+
<TableCellsIcon className="size-5 text-indigo-500" />
779+
{isTitleLoading ? (
780+
<span className="flex items-center gap-2 text-text-dimmed">
781+
<Spinner className="size-3" /> Generating title...
782+
</span>
783+
) : (
784+
queryTitle ?? "Results"
785+
)}
786+
</div>
787+
</Card.Header>
705788
<Card.Content className="min-h-0 flex-1 overflow-hidden p-0">
706789
<TSQLResultsTable
707790
rows={results.rows}
@@ -728,6 +811,8 @@ export default function Page() {
728811
columns={results.columns}
729812
chartConfig={chartConfig}
730813
onChartConfigChange={handleChartConfigChange}
814+
queryTitle={queryTitle}
815+
isTitleLoading={isTitleLoading}
731816
/>
732817
) : (
733818
<Paragraph variant="small" className="p-4 text-text-dimmed">
@@ -853,14 +938,26 @@ function ResultsChart({
853938
columns,
854939
chartConfig,
855940
onChartConfigChange,
941+
queryTitle,
942+
isTitleLoading,
856943
}: {
857944
rows: Record<string, unknown>[];
858945
columns: OutputColumnMetadata[];
859946
chartConfig: ChartConfiguration;
860947
onChartConfigChange: (config: ChartConfiguration) => void;
948+
queryTitle: string | null;
949+
isTitleLoading: boolean;
861950
}) {
862951
const [isOpen, setIsOpen] = useState(false);
863952

953+
const titleContent = isTitleLoading ? (
954+
<span className="flex items-center gap-2 text-text-dimmed">
955+
<Spinner className="size-3" /> Generating title...
956+
</span>
957+
) : (
958+
queryTitle ?? "Chart"
959+
);
960+
864961
return (
865962
<><ResizablePanelGroup className="h-full overflow-hidden">
866963
<ResizablePanel id="chart-results">
@@ -869,7 +966,7 @@ function ResultsChart({
869966
<Card.Header>
870967
<div className="flex items-center gap-1.5">
871968
<ArrowTrendingUpIcon className="size-5 text-indigo-500" />
872-
Chart
969+
{titleContent}
873970
</div>
874971
<Card.Accessory>
875972
<Button variant="minimal/small" LeadingIcon={ArrowsPointingOutIcon} onClick={() => setIsOpen(true)} />
@@ -890,7 +987,7 @@ function ResultsChart({
890987
<Dialog open={isOpen} onOpenChange={setIsOpen}>
891988
<DialogContent fullscreen>
892989
<DialogHeader>
893-
Chart
990+
{queryTitle ?? "Chart"}
894991
</DialogHeader>
895992
<div className="h-full min-h-0 flex-1 overflow-hidden w-full pt-4">
896993
<QueryResultsChart rows={rows} columns={columns} config={chartConfig} fullLegend={true} />
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { openai } from "@ai-sdk/openai";
2+
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
3+
import { z } from "zod";
4+
import { prisma } from "~/db.server";
5+
import { env } from "~/env.server";
6+
import { findProjectBySlug } from "~/models/project.server";
7+
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
8+
import { requireUserId } from "~/services/session.server";
9+
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
10+
import { AIQueryTitleService } from "~/v3/services/aiQueryTitleService.server";
11+
12+
const RequestSchema = z.object({
13+
query: z.string().min(1, "Query is required"),
14+
queryId: z.string().optional(),
15+
});
16+
17+
export async function action({ request, params }: ActionFunctionArgs) {
18+
const userId = await requireUserId(request);
19+
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
20+
21+
// Parse the request body
22+
const data = await request.json();
23+
const submission = RequestSchema.safeParse(data);
24+
25+
if (!submission.success) {
26+
return json({ success: false as const, error: "Invalid request data", title: null }, { status: 400 });
27+
}
28+
29+
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
30+
if (!project) {
31+
return json({ success: false as const, error: "Project not found", title: null }, { status: 404 });
32+
}
33+
34+
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
35+
if (!environment) {
36+
return json({ success: false as const, error: "Environment not found", title: null }, { status: 404 });
37+
}
38+
39+
if (!env.OPENAI_API_KEY) {
40+
return json(
41+
{ success: false as const, error: "OpenAI API key is not configured", title: null },
42+
{ status: 400 }
43+
);
44+
}
45+
46+
const { query, queryId } = submission.data;
47+
48+
const service = new AIQueryTitleService(openai(env.AI_RUN_FILTER_MODEL ?? "gpt-4o-mini"));
49+
50+
const result = await service.generateTitle(query);
51+
52+
if (!result.success) {
53+
return json({ success: false as const, error: result.error, title: null }, { status: 500 });
54+
}
55+
56+
// If a queryId was provided, update the CustomerQuery record with the title
57+
if (queryId) {
58+
await prisma.customerQuery.update({
59+
where: { id: queryId },
60+
data: { title: result.title },
61+
});
62+
}
63+
64+
return json({ success: true as const, title: result.title, error: null });
65+
}

apps/webapp/app/services/queryService.server.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -196,16 +196,11 @@ export async function executeQuery<TOut extends z.ZodSchema>(
196196
lastQuery.filterTo?.getTime() === (timeFilter?.to?.getTime() ?? undefined);
197197

198198
if (!isDuplicate) {
199-
const stats = result[1].stats;
200-
const byteSeconds = parseFloat(stats.byte_seconds) || 0;
201-
const costInCents = byteSeconds * env.CENTS_PER_QUERY_BYTE_SECOND;
202-
203199
await prisma.customerQuery.create({
204200
data: {
205201
query: options.query,
206202
scope: scopeToEnum[scope],
207-
stats: { ...stats },
208-
costInCents,
203+
stats: { ...result[1].stats },
209204
source: history.source,
210205
organizationId,
211206
projectId: scope === "project" || scope === "environment" ? projectId : null,

0 commit comments

Comments
 (0)