317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import { json } from "@remix-run/server-runtime";
|
|
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
|
|
import { z } from "zod";
|
|
import type { Prisma } from "@trigger.dev/database";
|
|
import { SessionId } from "@trigger.dev/core/v3/isomorphic";
|
|
import { prisma } from "~/db.server";
|
|
import { findProjectBySlug } from "~/models/project.server";
|
|
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
|
import { requireUserId } from "~/services/session.server";
|
|
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
|
import { mintSessionToken } from "~/services/realtime/mintSessionToken.server";
|
|
import { ensureRunForSession } from "~/services/realtime/sessionRunManager.server";
|
|
import { runStore } from "~/v3/runStore.server";
|
|
|
|
const PlaygroundAction = z.object({
|
|
intent: z.enum(["create", "start", "save", "delete"]),
|
|
agentSlug: z.string(),
|
|
// For create
|
|
conversationId: z.string().optional(),
|
|
// For start (replaces "trigger" — atomically creates the Session and
|
|
// triggers its first run, returns a session-scoped PAT)
|
|
chatId: z.string().optional(),
|
|
payload: z.string().optional(),
|
|
clientData: z.string().optional(),
|
|
tags: z.string().optional(),
|
|
machine: z.string().optional(),
|
|
maxAttempts: z.string().optional(),
|
|
maxDuration: z.string().optional(),
|
|
version: z.string().optional(),
|
|
region: z.string().optional(),
|
|
// For save
|
|
messages: z.string().optional(),
|
|
lastEventId: z.string().optional(),
|
|
// For delete
|
|
deleteConversationId: z.string().optional(),
|
|
});
|
|
|
|
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|
const userId = await requireUserId(request);
|
|
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
|
|
|
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
|
if (!project) {
|
|
return json({ error: "Project not found" }, { status: 404 });
|
|
}
|
|
|
|
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
|
if (!environment) {
|
|
return json({ error: "Environment not found" }, { status: 404 });
|
|
}
|
|
|
|
const formData = await request.formData();
|
|
const parsed = PlaygroundAction.safeParse(Object.fromEntries(formData));
|
|
if (!parsed.success) {
|
|
return json({ error: "Invalid request", details: parsed.error.issues }, { status: 400 });
|
|
}
|
|
|
|
const { intent } = parsed.data;
|
|
|
|
switch (intent) {
|
|
case "create": {
|
|
const { agentSlug } = parsed.data;
|
|
const chatId = crypto.randomUUID();
|
|
|
|
const conversation = await prisma.playgroundConversation.create({
|
|
data: {
|
|
chatId,
|
|
agentSlug,
|
|
projectId: project.id,
|
|
runtimeEnvironmentId: environment.id,
|
|
userId,
|
|
},
|
|
});
|
|
|
|
return json({
|
|
conversationId: conversation.id,
|
|
chatId,
|
|
});
|
|
}
|
|
|
|
case "start": {
|
|
const {
|
|
agentSlug,
|
|
chatId,
|
|
payload: payloadStr,
|
|
clientData,
|
|
tags: tagsStr,
|
|
machine,
|
|
maxAttempts,
|
|
maxDuration,
|
|
version,
|
|
region,
|
|
} = parsed.data;
|
|
|
|
if (!chatId) {
|
|
return json({ error: "chatId is required" }, { status: 400 });
|
|
}
|
|
|
|
// Parse the optional initial payload — used as the basePayload
|
|
// for the first run trigger. After session create, the agent
|
|
// reads subsequent messages from `.in/append` so the payload
|
|
// here is just the bootstrap.
|
|
let payload: Record<string, any> = {};
|
|
try {
|
|
payload = payloadStr ? (JSON.parse(payloadStr) as Record<string, any>) : {};
|
|
} catch {
|
|
return json({ error: "Invalid payload JSON" }, { status: 400 });
|
|
}
|
|
|
|
let parsedClientData: unknown;
|
|
try {
|
|
parsedClientData = clientData ? JSON.parse(clientData) : undefined;
|
|
} catch {
|
|
/* invalid JSON — fall through with undefined */
|
|
}
|
|
|
|
const tags = [
|
|
`chat:${chatId}`,
|
|
"playground:true",
|
|
...(tagsStr
|
|
? tagsStr
|
|
.split(",")
|
|
.map((t) => t.trim())
|
|
.filter(Boolean)
|
|
: []),
|
|
].slice(0, 5);
|
|
|
|
const triggerConfig = {
|
|
basePayload: {
|
|
// The first run boots before the user's first message lands on
|
|
// `.in/append`, so it sees `messages: []` and `trigger: "preload"`.
|
|
// Mirrors the defaults in `chat.createStartSessionAction` —
|
|
// chat.agent's runtime reads `payload.messages.length` so the
|
|
// field must be an array, not undefined.
|
|
messages: [],
|
|
trigger: "preload",
|
|
...payload,
|
|
chatId,
|
|
...(parsedClientData ? { metadata: parsedClientData } : {}),
|
|
},
|
|
...(machine ? { machine } : {}),
|
|
tags,
|
|
...(maxAttempts ? { maxAttempts: parseInt(maxAttempts, 10) } : {}),
|
|
...(maxDuration ? { maxDuration: parseInt(maxDuration, 10) } : {}),
|
|
...(version ? { lockToVersion: version } : {}),
|
|
...(region ? { region } : {}),
|
|
};
|
|
|
|
// Atomic: upsert the Session, then trigger the first run via
|
|
// the optimistic-claim path. The transport's `accessToken`
|
|
// callback hits this endpoint on initial start AND on 401 — the
|
|
// upsert + ensureRunForSession combo is idempotent so repeat
|
|
// calls converge to the same session and (if alive) reuse the
|
|
// existing run.
|
|
const { id: sessionId, friendlyId } = SessionId.generate();
|
|
const session = await prisma.session.upsert({
|
|
where: {
|
|
runtimeEnvironmentId_externalId: {
|
|
runtimeEnvironmentId: environment.id,
|
|
externalId: chatId,
|
|
},
|
|
},
|
|
create: {
|
|
id: sessionId,
|
|
friendlyId,
|
|
externalId: chatId,
|
|
type: "chat.agent",
|
|
taskIdentifier: agentSlug,
|
|
triggerConfig: triggerConfig as unknown as Prisma.InputJsonValue,
|
|
// Mark as a Test session — surfaced via the Test column in the
|
|
// Sessions table. Session tags stay empty; the triggered run still
|
|
// carries "playground:true" via triggerConfig.tags.
|
|
isTest: true,
|
|
projectId: project.id,
|
|
runtimeEnvironmentId: environment.id,
|
|
environmentType: environment.type,
|
|
organizationId: project.organizationId,
|
|
// Stamp the org's S2 basin so realtime reads on this
|
|
// session's `.in/.out` channels resolve without joining
|
|
// Organization. Null until per-org basins are provisioned.
|
|
streamBasinName: environment.organization.streamBasinName,
|
|
},
|
|
update: {
|
|
// Refresh trigger config in case agent version / params changed
|
|
triggerConfig: triggerConfig as unknown as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
const ensureResult = await ensureRunForSession({
|
|
session,
|
|
environment,
|
|
reason: "initial",
|
|
});
|
|
|
|
const run = await runStore.findRun(
|
|
{ id: ensureResult.runId },
|
|
{ select: { friendlyId: true } },
|
|
prisma
|
|
);
|
|
if (!run) {
|
|
return json({ error: "Triggered run not found" }, { status: 500 });
|
|
}
|
|
|
|
// Title: prefer the user message text on first start, else a
|
|
// generic placeholder. The conversation row is the playground's
|
|
// own surface — separate from the Session row that drives the
|
|
// trigger.
|
|
const firstMessage = payload?.messages?.[0];
|
|
const firstText =
|
|
firstMessage?.parts?.find((p: any) => p.type === "text")?.text ?? "New conversation";
|
|
const title = firstText.length > 60 ? firstText.slice(0, 60) + "..." : firstText;
|
|
|
|
const conversation = await prisma.playgroundConversation.upsert({
|
|
where: {
|
|
chatId_runtimeEnvironmentId: {
|
|
chatId,
|
|
runtimeEnvironmentId: environment.id,
|
|
},
|
|
},
|
|
create: {
|
|
chatId,
|
|
title,
|
|
agentSlug,
|
|
runId: ensureResult.runId,
|
|
clientData: parsedClientData as any,
|
|
projectId: project.id,
|
|
runtimeEnvironmentId: environment.id,
|
|
userId,
|
|
},
|
|
update: {
|
|
runId: ensureResult.runId,
|
|
clientData: parsedClientData as any,
|
|
title,
|
|
},
|
|
});
|
|
|
|
const publicAccessToken = await mintSessionToken(environment, chatId);
|
|
|
|
return json({
|
|
runId: run.friendlyId,
|
|
publicAccessToken,
|
|
conversationId: conversation.id,
|
|
});
|
|
}
|
|
|
|
case "save": {
|
|
const { chatId, messages: messagesStr, lastEventId } = parsed.data;
|
|
if (!chatId) {
|
|
return json({ error: "chatId is required" }, { status: 400 });
|
|
}
|
|
|
|
let messagesData: unknown;
|
|
try {
|
|
messagesData = messagesStr ? JSON.parse(messagesStr) : undefined;
|
|
} catch {
|
|
return json({ error: "Invalid messages JSON" }, { status: 400 });
|
|
}
|
|
|
|
// Extract title from the first user message if the conversation still has the default title.
|
|
// This handles the case where a preloaded conversation gets its first real message
|
|
// via the input stream (bypassing the trigger action that normally sets the title).
|
|
let titleUpdate: { title: string } | undefined;
|
|
if (messagesData && Array.isArray(messagesData)) {
|
|
const existing = await prisma.playgroundConversation.findFirst({
|
|
where: { chatId, runtimeEnvironmentId: environment.id, userId },
|
|
select: { title: true },
|
|
});
|
|
|
|
if (existing?.title === "New conversation") {
|
|
const firstUserMsg = messagesData.find((m: any) => m.role === "user") as
|
|
| Record<string, any>
|
|
| undefined;
|
|
const firstText =
|
|
firstUserMsg?.parts?.find((p: any) => p.type === "text")?.text ?? firstUserMsg?.content;
|
|
if (firstText && typeof firstText === "string") {
|
|
titleUpdate = {
|
|
title: firstText.length > 60 ? firstText.slice(0, 60) + "..." : firstText,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
await prisma.playgroundConversation.updateMany({
|
|
where: {
|
|
chatId,
|
|
runtimeEnvironmentId: environment.id,
|
|
userId,
|
|
},
|
|
data: {
|
|
...(messagesData ? { messages: messagesData as any } : {}),
|
|
...(lastEventId ? { lastEventId } : {}),
|
|
...titleUpdate,
|
|
},
|
|
});
|
|
|
|
return json({ ok: true });
|
|
}
|
|
|
|
case "delete": {
|
|
const { deleteConversationId } = parsed.data;
|
|
if (!deleteConversationId) {
|
|
return json({ error: "deleteConversationId is required" }, { status: 400 });
|
|
}
|
|
|
|
await prisma.playgroundConversation.deleteMany({
|
|
where: {
|
|
id: deleteConversationId,
|
|
runtimeEnvironmentId: environment.id,
|
|
userId,
|
|
},
|
|
});
|
|
|
|
return json({ ok: true });
|
|
}
|
|
}
|
|
};
|