223 lines
6.4 KiB
TypeScript
223 lines
6.4 KiB
TypeScript
import { openai } from "@ai-sdk/openai";
|
|
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
|
|
import { z } from "zod";
|
|
import { env } from "~/env.server";
|
|
import { findProjectBySlug } from "~/models/project.server";
|
|
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
|
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
|
|
import { requireUserId } from "~/services/session.server";
|
|
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
|
import { AIQueryService } from "~/v3/services/aiQueryService.server";
|
|
import { querySchemas } from "~/v3/querySchemas";
|
|
|
|
const RequestSchema = z.object({
|
|
prompt: z.string().min(1, "Prompt is required"),
|
|
mode: z.enum(["new", "edit"]).default("new"),
|
|
currentQuery: z.string().optional(),
|
|
});
|
|
|
|
export async function action({ request, params }: ActionFunctionArgs) {
|
|
const userId = await requireUserId(request);
|
|
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
|
|
|
// Parse the request body
|
|
const formData = await request.formData();
|
|
const submission = RequestSchema.safeParse(Object.fromEntries(formData));
|
|
|
|
if (!submission.success) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
type: "result",
|
|
success: false,
|
|
error: "Invalid request data",
|
|
}),
|
|
{
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
}
|
|
);
|
|
}
|
|
|
|
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
|
if (!project) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
type: "result",
|
|
success: false,
|
|
error: "Project not found",
|
|
}),
|
|
{
|
|
status: 404,
|
|
headers: { "Content-Type": "application/json" },
|
|
}
|
|
);
|
|
}
|
|
|
|
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
|
if (!environment) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
type: "result",
|
|
success: false,
|
|
error: "Environment not found",
|
|
}),
|
|
{
|
|
status: 404,
|
|
headers: { "Content-Type": "application/json" },
|
|
}
|
|
);
|
|
}
|
|
|
|
if (!env.OPENAI_API_KEY) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
type: "result",
|
|
success: false,
|
|
error: "OpenAI API key is not configured",
|
|
}),
|
|
{
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
}
|
|
);
|
|
}
|
|
|
|
const { prompt, mode, currentQuery } = submission.data;
|
|
|
|
const service = new AIQueryService(
|
|
querySchemas,
|
|
openai(env.AI_RUN_FILTER_MODEL ?? "gpt-4o-mini")
|
|
);
|
|
|
|
// Create a streaming response
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
const encoder = new TextEncoder();
|
|
|
|
const sendEvent = (event: {
|
|
type: string;
|
|
content?: string;
|
|
tool?: string;
|
|
args?: unknown;
|
|
result?: unknown;
|
|
success?: boolean;
|
|
query?: string;
|
|
error?: string;
|
|
filter?: AITimeFilter;
|
|
timeFilter?: AITimeFilter;
|
|
}) => {
|
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
|
};
|
|
|
|
try {
|
|
const result = service.streamQuery(prompt, { mode, currentQuery });
|
|
|
|
// Process the stream
|
|
for await (const part of result.fullStream) {
|
|
switch (part.type) {
|
|
case "text-delta": {
|
|
sendEvent({ type: "thinking", content: part.text });
|
|
break;
|
|
}
|
|
case "tool-call": {
|
|
sendEvent({
|
|
type: "tool_call",
|
|
tool: part.toolName,
|
|
args: part.input,
|
|
});
|
|
|
|
// If it's a setTimeFilter call, emit the time_filter event immediately
|
|
if (part.toolName === "setTimeFilter") {
|
|
const args = part.input as { period?: string; from?: string; to?: string };
|
|
sendEvent({
|
|
type: "time_filter",
|
|
filter: {
|
|
period: args.period,
|
|
from: args.from,
|
|
to: args.to,
|
|
},
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "error": {
|
|
sendEvent({
|
|
type: "result",
|
|
success: false,
|
|
error: part.error instanceof Error ? part.error.message : String(part.error),
|
|
});
|
|
break;
|
|
}
|
|
case "finish": {
|
|
// Extract query from the final text
|
|
const finalText = await result.text;
|
|
const query = extractQueryFromText(finalText);
|
|
const timeFilter = service.getPendingTimeFilter();
|
|
|
|
if (query) {
|
|
sendEvent({
|
|
type: "result",
|
|
success: true,
|
|
query,
|
|
timeFilter,
|
|
});
|
|
} else if (
|
|
finalText.toLowerCase().includes("cannot") ||
|
|
finalText.toLowerCase().includes("unable")
|
|
) {
|
|
sendEvent({
|
|
type: "result",
|
|
success: false,
|
|
error: finalText.slice(0, 300),
|
|
});
|
|
} else {
|
|
sendEvent({
|
|
type: "result",
|
|
success: false,
|
|
error: "Could not generate a valid query",
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
sendEvent({
|
|
type: "result",
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "An error occurred",
|
|
});
|
|
} finally {
|
|
controller.close();
|
|
}
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Extract a SQL query from the AI response text
|
|
*/
|
|
function extractQueryFromText(text: string): string | null {
|
|
// Try to extract from code block first
|
|
const codeBlockMatch = text.match(/```(?:sql)?\s*([\s\S]*?)```/i);
|
|
if (codeBlockMatch) {
|
|
return codeBlockMatch[1].trim();
|
|
}
|
|
|
|
// Try to find a SELECT statement
|
|
const selectMatch = text.match(/SELECT[\s\S]+?(?:LIMIT\s+\d+|;|$)/i);
|
|
if (selectMatch) {
|
|
return selectMatch[0].trim().replace(/;$/, "");
|
|
}
|
|
|
|
return null;
|
|
}
|