Files
triggerdotdev--trigger.dev/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts
2026-07-13 13:32:57 +08:00

271 lines
9.7 KiB
TypeScript

import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import {
chatExists,
createChat,
getChatMessages,
getSession,
listChats,
renameChat,
setChatPinned,
softDeleteChat,
} from "@internal/dashboard-agent-db";
import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
import type { UIMessage } from "ai";
import { z } from "zod";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { findProjectBySlug } from "~/models/project.server";
import {
dashboardAgentApiOrigin,
isDashboardAgentConfigured,
mintDashboardAgentToken,
mintDashboardAgentUserActorToken,
resolveDashboardAgentRepoSnapshot,
startDashboardAgentSession,
} from "~/services/dashboardAgent.server";
import { startDashboardAgentHeadStart } from "~/services/dashboardAgentHeadStart.server";
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { canAccessDashboardAgent } from "~/v3/canAccessDashboardAgent.server";
// The agent's tools address the canonical env name, not the dashboard URL slug.
const ENV_NAME_BY_TYPE: Record<string, string> = {
DEVELOPMENT: "dev",
STAGING: "staging",
PRODUCTION: "prod",
PREVIEW: "preview",
};
const ActionBody = z.object({
intent: z.enum(["start", "create", "token", "rename", "pin", "delete"]),
// Omitted for `create` (the server generates it); required for the rest.
chatId: z.string().min(1).optional(),
// The first user message (JSON UIMessage), for `create`.
message: z.string().optional(),
clientData: z.string().optional(),
title: z.string().optional(),
pinned: z.enum(["true", "false"]).optional(),
});
// History list, or — with ?chatId= — the stored transcript + session for resume.
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = user.id;
const { organizationSlug, projectParam } = EnvironmentParamSchema.parse(params);
if (
!(await canAccessDashboardAgent({
userId,
isAdmin: user.admin,
isImpersonating: user.isImpersonating,
organizationSlug,
}))
) {
return json({ error: "Not found" }, { status: 404 });
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) return json({ error: "Project not found" }, { status: 404 });
const chatId = new URL(request.url).searchParams.get("chatId");
if (chatId) {
const [messages, session] = await Promise.all([
getChatMessages(dashboardAgentDb, { chatId, userId }),
getSession(dashboardAgentDb, { chatId, userId }),
]);
return json({ messages: messages ?? [], session });
}
const chats = await listChats(dashboardAgentDb, {
organizationId: project.organizationId,
userId,
});
return json({ chats });
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = await requireUser(request);
const userId = user.id;
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
if (
!(await canAccessDashboardAgent({
userId,
isAdmin: user.admin,
isImpersonating: user.isImpersonating,
organizationSlug,
}))
) {
return json({ error: "Not found" }, { status: 404 });
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) return json({ error: "Project not found" }, { status: 404 });
const parsed = ActionBody.safeParse(Object.fromEntries(await request.formData()));
if (!parsed.success) return json({ error: "Invalid request" }, { status: 400 });
// Create a new chat: the SERVER generates the id and owns the chat record, so
// a client can never name another user's chat. Kicks off the first turn (head
// start when configured, else a cold session) and returns the id + token. The
// client mounts with that id and resumes the stream.
if (parsed.data.intent === "create") {
if (!isDashboardAgentConfigured()) {
return json({ error: "The dashboard agent is not configured." }, { status: 501 });
}
let firstMessage: UIMessage | undefined;
try {
firstMessage = parsed.data.message
? (JSON.parse(parsed.data.message) as UIMessage)
: undefined;
} catch {
return json({ error: "Invalid message" }, { status: 400 });
}
if (!firstMessage) return json({ error: "message is required" }, { status: 400 });
let clientData: Record<string, unknown> | undefined;
try {
clientData = parsed.data.clientData
? (JSON.parse(parsed.data.clientData) as Record<string, unknown>)
: undefined;
} catch {
/* invalid JSON — create without context metadata */
}
const chatId = generateFriendlyId("chat");
try {
await createChat(dashboardAgentDb, {
id: chatId,
organizationId: project.organizationId,
userId,
...(clientData ? { metadata: { context: clientData } } : {}),
});
const runtimeEnv = await $replica.runtimeEnvironment.findFirst({
where: { projectId: project.id, slug: envParam },
select: { type: true },
});
const environmentName = runtimeEnv ? ENV_NAME_BY_TYPE[runtimeEnv.type] : undefined;
const repoSnapshot = await resolveDashboardAgentRepoSnapshot(project.id);
const headStarted = Boolean(env.ANTHROPIC_API_KEY);
if (headStarted) {
// Head start runs the warm step-1 with this first message and injects the
// delegated token + context into the run's payload server-side.
await startDashboardAgentHeadStart({
chatId,
messages: [firstMessage],
mode: repoSnapshot ? "code" : "assistant",
metadata: {
// The agent validates the run metadata against its clientDataSchema
// (userId, organizationId, …), so the per-turn clientData has to be
// present alongside the injected auth/context fields.
...(clientData ?? {}),
userActorToken: await mintDashboardAgentUserActorToken(userId),
apiOrigin: dashboardAgentApiOrigin(),
projectRef: project.externalRef,
environmentName,
...(repoSnapshot ? { repoSnapshot } : {}),
},
});
} else {
// Cold start: create the session (preload); the client sends the first
// message through the transport, where the `in` proxy injects the token.
await startDashboardAgentSession({ chatId, clientData });
}
const publicAccessToken = await mintDashboardAgentToken(chatId);
return json({ chatId, publicAccessToken, headStarted });
} catch (error) {
logger.error("Failed to create dashboard agent chat", { chatId, error });
return json(
{ error: "The dashboard agent couldn't start. Please try again in a moment." },
{ status: 500 }
);
}
}
const { intent, chatId } = parsed.data;
if (!chatId) return json({ error: "chatId is required" }, { status: 400 });
switch (intent) {
case "start": {
if (!isDashboardAgentConfigured()) {
return json({ error: "The dashboard agent is not configured." }, { status: 501 });
}
// Resume-only: new chats are created via the `create` intent (server-owned
// id). The transport falls back here to re-establish a session for an
// existing chat (e.g. after its token expired), so verify ownership before
// issuing one — a client-supplied chatId must belong to the caller.
if (
!(await chatExists(dashboardAgentDb, {
chatId,
userId,
organizationId: project.organizationId,
}))
) {
return json({ error: "Chat not found" }, { status: 404 });
}
let clientData: Record<string, unknown> | undefined;
try {
clientData = parsed.data.clientData
? (JSON.parse(parsed.data.clientData) as Record<string, unknown>)
: undefined;
} catch {
/* invalid JSON — start without metadata */
}
try {
const { publicAccessToken } = await startDashboardAgentSession({ chatId, clientData });
return json({ publicAccessToken });
} catch (error) {
logger.error("Failed to start dashboard agent session", { chatId, error });
return json(
{ error: "The dashboard agent couldn't start. Please try again in a moment." },
{ status: 500 }
);
}
}
case "token": {
if (!isDashboardAgentConfigured()) {
return json({ error: "The dashboard agent is not configured." }, { status: 501 });
}
// Only mint a session token for a chat the caller owns, so a client-supplied
// chatId can't be used to get a token for someone else's session.
if (
!(await chatExists(dashboardAgentDb, {
chatId,
userId,
organizationId: project.organizationId,
}))
) {
return json({ error: "Chat not found" }, { status: 404 });
}
return json({ token: await mintDashboardAgentToken(chatId) });
}
case "rename": {
if (!parsed.data.title) return json({ error: "title is required" }, { status: 400 });
await renameChat(dashboardAgentDb, { chatId, userId, title: parsed.data.title });
return json({ ok: true });
}
case "pin": {
await setChatPinned(dashboardAgentDb, {
chatId,
userId,
pinned: parsed.data.pinned === "true",
});
return json({ ok: true });
}
case "delete": {
await softDeleteChat(dashboardAgentDb, { chatId, userId });
return json({ ok: true });
}
}
};