diff --git a/cli/package.json b/cli/package.json index f7e9b24..c714fb3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,16 +1,18 @@ { - "name": "create-t3-app", + "name": "create-trelent-app", "version": "7.40.0", - "description": "Create web application with the t3 stack", + "description": "Create an agent-powered web application with the t3 stack, Clerk, and the Trelent agent orchestrator", "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/t3-oss/create-t3-app.git", + "url": "https://github.com/trelent/create-agent-app.git", "directory": "cli" }, "keywords": [ - "create-t3-app", - "init.tips", + "create-trelent-app", + "create-agent-app", + "trelent", + "agents", "next.js", "t3-stack", "tailwind", @@ -20,7 +22,8 @@ "type": "module", "exports": "./dist/index.js", "bin": { - "create-t3-app": "./dist/index.js" + "create-trelent-app": "./dist/index.js", + "create-agent-app": "./dist/index.js" }, "files": [ "dist", diff --git a/cli/src/cli/index.ts b/cli/src/cli/index.ts index 3d55102..9dfcc31 100644 --- a/cli/src/cli/index.ts +++ b/cli/src/cli/index.ts @@ -36,6 +36,16 @@ interface CliFlags { /** @internal Used in CI. */ betterAuth: boolean; /** @internal Used in CI. */ + clerk: boolean; + /** @internal Used in CI. */ + shadcn: boolean; + /** @internal Used in CI. */ + aiElements: boolean; + /** @internal Used in CI. */ + trelent: boolean; + /** @internal Used in CI. */ + sandboxName: string; + /** @internal Used in CI. */ appRouter: boolean; /** @internal Used in CI. */ dbProvider: DatabaseProvider; @@ -50,11 +60,21 @@ interface CliResults { packages: AvailablePackages[]; flags: CliFlags; databaseProvider: DatabaseProvider; + sandboxName: string; } const defaultOptions: CliResults = { appName: DEFAULT_APP_NAME, - packages: ["nextAuth", "prisma", "tailwind", "trpc", "eslint"], + packages: [ + "clerk", + "drizzle", + "tailwind", + "shadcn", + "aiElements", + "trpc", + "trelent", + "eslint", + ], flags: { noGit: false, noInstall: false, @@ -66,13 +86,19 @@ const defaultOptions: CliResults = { drizzle: false, nextAuth: false, betterAuth: false, + clerk: false, + shadcn: false, + aiElements: false, + trelent: false, + sandboxName: "my-sandbox", importAlias: "~/", - appRouter: false, + appRouter: true, dbProvider: "sqlite", eslint: false, biome: false, }, databaseProvider: "sqlite", + sandboxName: "my-sandbox", }; export const runCli = async (): Promise => { @@ -124,6 +150,35 @@ export const runCli = async (): Promise => { "Experimental: Boolean value if we should install BetterAuth. Must be used in conjunction with `--CI`.", (value) => !!value && value !== "false" ) + /** @experimental Used for CI E2E tests. Used in conjunction with `--CI` to skip prompting. */ + .option( + "--clerk [boolean]", + "Experimental: Boolean value if we should install Clerk. Must be used in conjunction with `--CI`.", + (value) => !!value && value !== "false" + ) + /** @experimental Used for CI E2E tests. Used in conjunction with `--CI` to skip prompting. */ + .option( + "--shadcn [boolean]", + "Experimental: Boolean value if we should set up shadcn/ui. Must be used in conjunction with `--CI`.", + (value) => !!value && value !== "false" + ) + /** @experimental Used for CI E2E tests. Used in conjunction with `--CI` to skip prompting. */ + .option( + "--aiElements [boolean]", + "Experimental: Boolean value if we should vendor AI Elements components. Must be used in conjunction with `--CI`.", + (value) => !!value && value !== "false" + ) + /** @experimental Used for CI E2E tests. Used in conjunction with `--CI` to skip prompting. */ + .option( + "--trelent [boolean]", + "Experimental: Boolean value if we should set up a Trelent agent sandbox. Must be used in conjunction with `--CI`.", + (value) => !!value && value !== "false" + ) + .option( + "--sandboxName [name]", + "The name of the Trelent sandbox to scaffold (used with --trelent)", + defaultOptions.flags.sandboxName + ) /** @experimental - Used for CI E2E tests. Used in conjunction with `--CI` to skip prompting. */ .option( "--prisma [boolean]", @@ -205,10 +260,14 @@ export const runCli = async (): Promise => { cliResults.packages = []; if (cliResults.flags.trpc) cliResults.packages.push("trpc"); if (cliResults.flags.tailwind) cliResults.packages.push("tailwind"); + if (cliResults.flags.shadcn) cliResults.packages.push("shadcn"); + if (cliResults.flags.aiElements) cliResults.packages.push("aiElements"); if (cliResults.flags.prisma) cliResults.packages.push("prisma"); if (cliResults.flags.drizzle) cliResults.packages.push("drizzle"); if (cliResults.flags.nextAuth) cliResults.packages.push("nextAuth"); if (cliResults.flags.betterAuth) cliResults.packages.push("betterAuth"); + if (cliResults.flags.clerk) cliResults.packages.push("clerk"); + if (cliResults.flags.trelent) cliResults.packages.push("trelent"); if (cliResults.flags.eslint) cliResults.packages.push("eslint"); if (cliResults.flags.biome) cliResults.packages.push("biome"); if (cliResults.flags.prisma && cliResults.flags.drizzle) { @@ -226,6 +285,27 @@ export const runCli = async (): Promise => { logger.warn("Incompatible combination NextAuth + BetterAuth. Exiting."); process.exit(0); } + if ( + cliResults.flags.clerk && + (cliResults.flags.nextAuth || cliResults.flags.betterAuth) + ) { + logger.warn( + "Incompatible combination Clerk + NextAuth/BetterAuth. Exiting." + ); + process.exit(0); + } + if (cliResults.flags.shadcn && !cliResults.flags.tailwind) { + logger.warn("shadcn/ui requires Tailwind CSS. Exiting."); + process.exit(0); + } + if (cliResults.flags.aiElements && !cliResults.flags.shadcn) { + logger.warn("AI Elements requires shadcn/ui. Exiting."); + process.exit(0); + } + if (cliResults.flags.clerk && !cliResults.flags.appRouter) { + logger.warn("Clerk support requires the App Router. Enabling it."); + cliResults.flags.appRouter = true; + } if (databaseProviders.includes(cliResults.flags.dbProvider) === false) { logger.warn( `Incompatible database provided. Use: ${databaseProviders.join(", ")}. Exiting.` @@ -239,6 +319,9 @@ export const runCli = async (): Promise => { ? cliResults.flags.dbProvider : "sqlite"; + cliResults.sandboxName = + cliResults.flags.sandboxName || defaultOptions.sandboxName; + return cliResults; } @@ -301,10 +384,9 @@ export const runCli = async (): Promise => { { value: "none", label: "None" }, { value: "next-auth", label: "NextAuth.js" }, { value: "better-auth", label: "BetterAuth" }, - // Maybe later - // { value: "clerk", label: "Clerk" }, + { value: "clerk", label: "Clerk" }, ], - initialValue: "none", + initialValue: "clerk", }); }, database: () => { @@ -315,10 +397,43 @@ export const runCli = async (): Promise => { { value: "prisma", label: "Prisma" }, { value: "drizzle", label: "Drizzle" }, ], - initialValue: "none", + initialValue: "drizzle", }); }, - appRouter: () => { + shadcn: ({ results }) => { + if (!results.styling) return; + return p.confirm({ + message: + "Would you like to set up shadcn/ui (with AI Elements chat components)?", + initialValue: true, + }); + }, + trelent: () => { + return p.confirm({ + message: + "Would you like to set up a Trelent agent sandbox (@trelent/agents)?", + initialValue: true, + }); + }, + sandboxName: ({ results }) => { + if (!results.trelent) return; + return p.text({ + message: "What should your sandbox be called?", + defaultValue: defaultOptions.sandboxName, + placeholder: defaultOptions.sandboxName, + validate: (input) => { + if (input && !/^[a-z0-9][a-z0-9._-]*$/.test(input)) { + return "Sandbox names must be lowercase letters, numbers, '.', '_' or '-' (Docker image naming rules)"; + } + return undefined; + }, + }); + }, + appRouter: ({ results }) => { + if (results.authentication === "clerk") { + p.note("Clerk support uses the Next.js App Router."); + return; + } return p.confirm({ message: "Would you like to use Next.js App Router?", initialValue: true, @@ -385,22 +500,35 @@ export const runCli = async (): Promise => { const packages: AvailablePackages[] = []; if (project.styling) packages.push("tailwind"); + if (project.shadcn) packages.push("shadcn", "aiElements"); if (project.trpc) packages.push("trpc"); if (project.authentication === "next-auth") packages.push("nextAuth"); if (project.authentication === "better-auth") packages.push("betterAuth"); + if (project.authentication === "clerk") packages.push("clerk"); if (project.database === "prisma") packages.push("prisma"); if (project.database === "drizzle") packages.push("drizzle"); + if (project.trelent) packages.push("trelent"); if (project.linter === "eslint") packages.push("eslint"); if (project.linter === "biome") packages.push("biome"); + // Clerk integration is app-router only + const appRouter = + project.authentication === "clerk" + ? true + : (project.appRouter ?? cliResults.flags.appRouter); + return { appName: project.name ?? cliResults.appName, packages, databaseProvider: (project.databaseProvider as DatabaseProvider) || "sqlite", + sandboxName: + typeof project.sandboxName === "string" + ? project.sandboxName + : defaultOptions.sandboxName, flags: { ...cliResults.flags, - appRouter: project.appRouter ?? cliResults.flags.appRouter, + appRouter: appRouter === true, noGit: !project.git || cliResults.flags.noGit, noInstall: !project.install || cliResults.flags.noInstall, importAlias: project.importAlias ?? cliResults.flags.importAlias, diff --git a/cli/src/consts.ts b/cli/src/consts.ts index df47416..67b19ba 100644 --- a/cli/src/consts.ts +++ b/cli/src/consts.ts @@ -14,5 +14,5 @@ export const TITLE_TEXT = ` ___ ___ ___ __ _____ ___ _____ ____ __ __ | (__| / _| / /\\ \\| | | _| | | |_ \\ / /\\ \\| _/ _/ \\___|_|_\\___|_/‾‾\\_\\_| |___| |_| |___/ /_/‾‾\\_\\_| |_| `; -export const DEFAULT_APP_NAME = "my-t3-app"; -export const CREATE_T3_APP = "create-t3-app"; +export const DEFAULT_APP_NAME = "my-agent-app"; +export const CREATE_T3_APP = "create-trelent-app"; diff --git a/cli/src/helpers/createProject.ts b/cli/src/helpers/createProject.ts index 81b3854..5c3c864 100644 --- a/cli/src/helpers/createProject.ts +++ b/cli/src/helpers/createProject.ts @@ -24,6 +24,7 @@ interface CreateProjectOptions { importAlias: string; appRouter: boolean; databaseProvider: DatabaseProvider; + sandboxName: string; } export const createProject = async ({ @@ -33,9 +34,12 @@ export const createProject = async ({ noInstall, appRouter, databaseProvider, + sandboxName, }: CreateProjectOptions) => { const pkgManager = getUserPkgManager(); const projectDir = path.resolve(process.cwd(), projectName); + // The Next.js app lives in /web; sandboxes/ sits next to it + const webDir = path.join(projectDir, "web"); // Bootstraps the base Next.js application await scaffoldProject({ @@ -52,12 +56,13 @@ export const createProject = async ({ installPackages({ projectName, scopedAppName, - projectDir, + projectDir: webDir, pkgManager, packages, noInstall, appRouter, databaseProvider, + sandboxName, }); // Select necessary _app,index / layout,page files @@ -65,14 +70,14 @@ export const createProject = async ({ // Replace next.config fs.copyFileSync( path.join(PKG_ROOT, "template/extras/config/next-config-appdir.js"), - path.join(projectDir, "next.config.js") + path.join(webDir, "next.config.js") ); - selectLayoutFile({ projectDir, packages }); - selectPageFile({ projectDir, packages }); + selectLayoutFile({ projectDir: webDir, packages }); + selectPageFile({ projectDir: webDir, packages }); } else { - selectAppFile({ projectDir, packages }); - selectIndexFile({ projectDir, packages }); + selectAppFile({ projectDir: webDir, packages }); + selectIndexFile({ projectDir: webDir, packages }); } // If no tailwind, select use css modules @@ -82,7 +87,7 @@ export const createProject = async ({ "template/extras/src/index.module.css" ); const indexModuleCssDest = path.join( - projectDir, + webDir, "src", appRouter ? "app" : "pages", "index.module.css" diff --git a/cli/src/helpers/logNextSteps.ts b/cli/src/helpers/logNextSteps.ts index 9ba296b..9940d14 100644 --- a/cli/src/helpers/logNextSteps.ts +++ b/cli/src/helpers/logNextSteps.ts @@ -11,6 +11,7 @@ export const logNextSteps = async ({ noInstall, projectDir, databaseProvider, + sandboxName, }: Pick< InstallerOptions, | "projectName" @@ -19,13 +20,12 @@ export const logNextSteps = async ({ | "projectDir" | "appRouter" | "databaseProvider" + | "sandboxName" >) => { const pkgManager = getUserPkgManager(); logger.info("Next steps:"); - if (projectName !== ".") { - logger.info(` cd ${projectName}`); - } + logger.info(` cd ${projectName === "." ? "web" : `${projectName}/web`}`); if (noInstall) { // To reflect yarn's default behavior of installing packages when no additional args provided if (pkgManager === "yarn") { @@ -53,12 +53,28 @@ export const logNextSteps = async ({ ); } + if (packages?.trelent.inUse) { + const sandbox = sandboxName ?? "my-sandbox"; + logger.info( + ` Build your sandbox image: docker build -t ${sandbox}:latest ../sandboxes/${sandbox}` + ); + logger.info( + " Point TRELENT_API_URL (and client credentials, if any) at your orchestrator in .env" + ); + } + if (["npm", "bun"].includes(pkgManager)) { logger.info(` ${pkgManager} run dev`); } else { logger.info(` ${pkgManager} dev`); } + if (packages?.clerk.inUse) { + logger.info( + " Clerk starts in keyless mode - claim the generated keys from the link printed by `dev`" + ); + } + if (!(await isInsideGitRepo(projectDir)) && !isRootGitRepo(projectDir)) { logger.info(` git init`); } diff --git a/cli/src/helpers/scaffoldProject.ts b/cli/src/helpers/scaffoldProject.ts index f258ddd..27b51c0 100644 --- a/cli/src/helpers/scaffoldProject.ts +++ b/cli/src/helpers/scaffoldProject.ts @@ -84,10 +84,34 @@ export const scaffoldProject = async ({ spinner.start(); - fs.copySync(srcDir, projectDir); + // The Next.js app is scaffolded into /web; sandbox definitions + // (added by the trelent installer) live in /sandboxes. + const webDir = path.join(projectDir, "web"); + fs.copySync(srcDir, webDir); fs.renameSync( - path.join(projectDir, "_gitignore"), - path.join(projectDir, ".gitignore") + path.join(webDir, "_gitignore"), + path.join(webDir, ".gitignore") + ); + + const displayName = projectName === "." ? "your app" : projectName; + fs.writeFileSync( + path.join(projectDir, "README.md"), + `# ${displayName} + +Scaffolded with [create-agent-app](https://github.com/trelent/create-agent-app). + +## Layout + +- \`web/\` - the Next.js application ([T3 Stack](https://create.t3.gg/)) +- \`sandboxes/\` - Docker images your agents run in, orchestrated by + [@trelent/agents](https://www.npmjs.com/package/@trelent/agents) + +## Getting started + +See \`web/README.md\` for the application, and the README inside each sandbox +directory for how to build and publish its image. +`, + "utf-8" ); const scaffoldedName = diff --git a/cli/src/helpers/selectBoilerplate.ts b/cli/src/helpers/selectBoilerplate.ts index a2a6ad3..f118f3d 100644 --- a/cli/src/helpers/selectBoilerplate.ts +++ b/cli/src/helpers/selectBoilerplate.ts @@ -54,8 +54,13 @@ export const selectLayoutFile = ({ const usingTw = packages.tailwind.inUse; const usingTRPC = packages.trpc.inUse; + const usingClerk = packages.clerk.inUse; let layoutFile = "base.tsx"; - if (usingTRPC && usingTw) { + if (usingClerk && usingTRPC) { + layoutFile = "with-clerk-trpc.tsx"; + } else if (usingClerk) { + layoutFile = "with-clerk.tsx"; + } else if (usingTRPC && usingTw) { layoutFile = "with-trpc-tw.tsx"; } else if (usingTRPC && !usingTw) { layoutFile = "with-trpc.tsx"; @@ -113,14 +118,55 @@ export const selectPageFile = ({ packages, }: SelectBoilerplateProps) => { const indexFileDir = path.join(PKG_ROOT, "template/extras/src/app/page"); + const extrasDir = path.join(PKG_ROOT, "template/extras"); const usingTRPC = packages.trpc.inUse; const usingTw = packages.tailwind.inUse; const usingAuth = packages?.nextAuth.inUse; const usingBetterAuth = packages?.betterAuth.inUse; + const usingClerk = packages?.clerk.inUse; + const usingDb = packages.prisma.inUse || packages.drizzle.inUse; + const usingRuns = + usingClerk && + usingTRPC && + usingDb && + packages.trelent.inUse && + packages.shadcn.inUse && + packages.aiElements.inUse; + + if (usingRuns) { + // The full agent-runs experience: home page to start runs, a chat-style + // run page at /runs/[id], and the components both are built from. + fs.copySync( + path.join(extrasDir, "src/app/page/with-runs.tsx"), + path.join(projectDir, "src/app/page.tsx") + ); + fs.copySync( + path.join(extrasDir, "src/app/runs"), + path.join(projectDir, "src/app/runs") + ); + for (const component of [ + "create-run.tsx", + "run-list.tsx", + "run-status-badge.tsx", + "run-thread.tsx", + ]) { + fs.copySync( + path.join(extrasDir, "src/app/_components", component), + path.join(projectDir, "src/app/_components", component) + ); + } + fs.copySync( + path.join(extrasDir, "src/lib/models.ts"), + path.join(projectDir, "src/lib/models.ts") + ); + return; + } let indexFile = "base.tsx"; - if (usingTRPC && usingTw && usingBetterAuth) { + if (usingClerk) { + indexFile = "with-clerk.tsx"; + } else if (usingTRPC && usingTw && usingBetterAuth) { indexFile = "with-better-auth-trpc-tw.tsx"; } else if (usingTRPC && !usingTw && usingBetterAuth) { indexFile = "with-better-auth-trpc.tsx"; diff --git a/cli/src/index.ts b/cli/src/index.ts index 47dce51..9e9bea4 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -41,6 +41,7 @@ const main = async () => { packages, flags: { noGit, noInstall, importAlias, appRouter }, databaseProvider, + sandboxName, } = await runCli(); const usePackages = buildPkgInstallerMap(packages, databaseProvider); @@ -56,11 +57,15 @@ const main = async () => { importAlias, noInstall, appRouter, + sandboxName, }); + // The Next.js app lives in /web + const webDir = path.join(projectDir, "web"); + // Write name to package.json const pkgJson = fs.readJSONSync( - path.join(projectDir, "package.json") + path.join(webDir, "package.json") ) as CT3APackageJSON; pkgJson.name = scopedAppName; pkgJson.ct3aMetadata = { initVersion: getVersion() }; @@ -68,32 +73,32 @@ const main = async () => { // ? Bun doesn't support this field (yet) if (pkgManager !== "bun") { const { stdout } = await execa(pkgManager, ["-v"], { - cwd: projectDir, + cwd: webDir, }); pkgJson.packageManager = `${pkgManager}@${stdout.trim()}`; } - fs.writeJSONSync(path.join(projectDir, "package.json"), pkgJson, { + fs.writeJSONSync(path.join(webDir, "package.json"), pkgJson, { spaces: 2, }); // update import alias in any generated files if not using the default if (importAlias !== "~/") { - setImportAlias(projectDir, importAlias); + setImportAlias(webDir, importAlias); } if (!noInstall) { - await installDependencies({ projectDir }); + await installDependencies({ projectDir: webDir }); if (usePackages.prisma.inUse) { logger.info("Generating Prisma client..."); - await execa("npx", ["prisma", "generate"], { cwd: projectDir }); + await execa("npx", ["prisma", "generate"], { cwd: webDir }); logger.info("Successfully generated Prisma client!"); } await formatProject({ pkgManager, - projectDir, + projectDir: webDir, eslint: packages.includes("eslint"), biome: packages.includes("biome"), }); @@ -110,6 +115,7 @@ const main = async () => { noInstall, projectDir, databaseProvider, + sandboxName, }); process.exit(0); diff --git a/cli/src/installers/aiElements.ts b/cli/src/installers/aiElements.ts new file mode 100644 index 0000000..edd5b46 --- /dev/null +++ b/cli/src/installers/aiElements.ts @@ -0,0 +1,27 @@ +import path from "path"; +import fs from "fs-extra"; + +import { PKG_ROOT } from "~/consts.js"; +import { type Installer } from "~/installers/index.js"; +import { addPackageDependency } from "~/utils/addPackageDependency.js"; + +/** + * Vendors AI Elements (https://elements.ai-sdk.dev) chat components - + * conversation, message, prompt-input, and loader - the same files + * `npx ai-elements add ` would install. They build on the + * shadcn/ui components, so this installer requires the shadcn package. + */ +export const aiElementsInstaller: Installer = ({ projectDir }) => { + addPackageDependency({ + projectDir, + dependencies: ["ai", "streamdown", "use-stick-to-bottom", "nanoid"], + devMode: false, + }); + + const extrasDir = path.join(PKG_ROOT, "template/extras"); + + fs.copySync( + path.join(extrasDir, "src/components/ai-elements"), + path.join(projectDir, "src/components/ai-elements") + ); +}; diff --git a/cli/src/installers/clerk.ts b/cli/src/installers/clerk.ts new file mode 100644 index 0000000..65444e4 --- /dev/null +++ b/cli/src/installers/clerk.ts @@ -0,0 +1,22 @@ +import path from "path"; +import fs from "fs-extra"; + +import { PKG_ROOT } from "~/consts.js"; +import { type Installer } from "~/installers/index.js"; +import { addPackageDependency } from "~/utils/addPackageDependency.js"; + +export const clerkInstaller: Installer = ({ projectDir }) => { + addPackageDependency({ + projectDir, + dependencies: ["@clerk/nextjs"], + devMode: false, + }); + + const extrasDir = path.join(PKG_ROOT, "template/extras"); + + // clerkMiddleware is required for auth() to work in RSCs and route handlers + fs.copySync( + path.join(extrasDir, "src/middleware/with-clerk.ts"), + path.join(projectDir, "src/middleware.ts") + ); +}; diff --git a/cli/src/installers/dbContainer.ts b/cli/src/installers/dbContainer.ts index 0a13f19..f0e518d 100644 --- a/cli/src/installers/dbContainer.ts +++ b/cli/src/installers/dbContainer.ts @@ -24,8 +24,11 @@ export const dbContainerInstaller: Installer = ({ const scriptText = fs.readFileSync(scriptSrc, "utf-8"); const scriptDest = path.join(projectDir, "start-database.sh"); // for configuration with postgresql and mysql when project is created with '.' project name + // (projectDir is /web, so the project name comes from the parent dir) const [projectNameParsed] = - projectName === "." ? parseNameAndPath(projectDir) : [projectName]; + projectName === "." + ? parseNameAndPath(path.dirname(projectDir)) + : [projectName]; // Sanitize the project name for Docker container usage const sanitizedProjectName = sanitizeName(projectNameParsed); diff --git a/cli/src/installers/dependencyVersionMap.ts b/cli/src/installers/dependencyVersionMap.ts index dd73806..9e1221d 100644 --- a/cli/src/installers/dependencyVersionMap.ts +++ b/cli/src/installers/dependencyVersionMap.ts @@ -11,13 +11,36 @@ export const dependencyVersionMap = { // Better-Auth "better-auth": "^1.3", + // Clerk + "@clerk/nextjs": "^7.5.1", + + // Trelent Agent Orchestrator + "@trelent/agents": "^0.2.9", + + // shadcn/ui + "radix-ui": "^1.5.0", + "class-variance-authority": "^0.7.1", + clsx: "^2.1.1", + "tailwind-merge": "^3.6.0", + "lucide-react": "^1.17.0", + cmdk: "^1.1.1", + "tw-animate-css": "^1.4.0", + + // AI Elements + ai: "^6.0.0", + streamdown: "^2.5.0", + "use-stick-to-bottom": "^1.1.6", + nanoid: "^5.1.11", + // Prisma prisma: "^6.6.0", "@prisma/client": "^6.6.0", "@prisma/adapter-planetscale": "^6.6.0", // Drizzle - "drizzle-kit": "^0.30.5", + // ^0.31.10 - older versions mis-introspect Postgres 18's named NOT NULL + // constraints and generate destructive diffs on every `db:push`. + "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.41.0", mysql2: "^3.11.0", "@planetscale/database": "^1.19.0", diff --git a/cli/src/installers/drizzle.ts b/cli/src/installers/drizzle.ts index 088fd57..a66195e 100644 --- a/cli/src/installers/drizzle.ts +++ b/cli/src/installers/drizzle.ts @@ -43,11 +43,15 @@ export const drizzleInstaller: Installer = ({ ); const configDest = path.join(projectDir, "drizzle.config.ts"); + // The clerk schema ships the user-runs table for the Trelent runs + // experience; without trelent the example post router needs the base schema. const schemaBaseName = packages?.betterAuth.inUse ? "with-better-auth" : packages?.nextAuth.inUse ? "with-auth" - : "base"; + : packages?.clerk.inUse && packages?.trelent.inUse + ? "with-clerk" + : "base"; const schemaSrc = path.join( extrasDir, "src/server/db/schema-drizzle", diff --git a/cli/src/installers/envVars.ts b/cli/src/installers/envVars.ts index 6b3b4c2..4723070 100644 --- a/cli/src/installers/envVars.ts +++ b/cli/src/installers/envVars.ts @@ -13,6 +13,7 @@ export const envVariablesInstaller: Installer = ({ }) => { const usingNextAuth = packages?.nextAuth.inUse; const usingBetterAuth = packages?.betterAuth.inUse; + const usingClerk = packages?.clerk.inUse; const usingPrisma = packages?.prisma.inUse; const usingDrizzle = packages?.drizzle.inUse; @@ -22,6 +23,7 @@ export const envVariablesInstaller: Installer = ({ const envContent = getEnvContent( !!usingNextAuth, !!usingBetterAuth, + !!usingClerk, !!usingPrisma, !!usingDrizzle, databaseProvider, @@ -33,15 +35,18 @@ export const envVariablesInstaller: Installer = ({ if (usingPlanetScale) { if (usingBetterAuth) envFile = "with-better-auth-db-planetscale.js"; else if (usingNextAuth) envFile = "with-auth-db-planetscale.js"; + else if (usingClerk) envFile = "with-clerk-db.js"; else envFile = "with-db-planetscale.js"; } else { if (usingBetterAuth) envFile = "with-better-auth-db.js"; else if (usingNextAuth) envFile = "with-auth-db.js"; + else if (usingClerk) envFile = "with-clerk-db.js"; else envFile = "with-db.js"; } } else { if (usingBetterAuth) envFile = "with-better-auth.js"; else if (usingNextAuth) envFile = "with-auth.js"; + else if (usingClerk) envFile = "with-clerk.js"; } if (envFile !== "") { @@ -80,6 +85,7 @@ export const envVariablesInstaller: Installer = ({ const getEnvContent = ( usingNextAuth: boolean, usingBetterAuth: boolean, + usingClerk: boolean, usingPrisma: boolean, usingDrizzle: boolean, databaseProvider: DatabaseProvider, @@ -114,6 +120,16 @@ BETTER_AUTH_SECRET="" # Better Auth GitHub OAuth BETTER_AUTH_GITHUB_CLIENT_ID="" BETTER_AUTH_GITHUB_CLIENT_SECRET="" +`; + + if (usingClerk) + content += ` +# Clerk +# In development you can leave these empty - Clerk runs in keyless mode and +# provisions development keys for you on first run. +# https://clerk.com/docs/upgrade-guides/keyless +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="" +CLERK_SECRET_KEY="" `; if (usingPrisma) @@ -144,7 +160,13 @@ DATABASE_URL='mysql://YOUR_MYSQL_URL_HERE?sslaccept=strict'`; content += "\n"; } - if (!usingNextAuth && !usingBetterAuth && !usingPrisma && !usingDrizzle) + if ( + !usingNextAuth && + !usingBetterAuth && + !usingClerk && + !usingPrisma && + !usingDrizzle + ) content += ` # Example: # SERVERVAR="foo" diff --git a/cli/src/installers/eslint.ts b/cli/src/installers/eslint.ts index e92637d..cbf7d56 100644 --- a/cli/src/installers/eslint.ts +++ b/cli/src/installers/eslint.ts @@ -69,5 +69,20 @@ export const dynamicEslintInstaller: Installer = ({ projectDir, packages }) => { ); const eslintConfigDest = path.join(projectDir, "eslint.config.js"); - fs.copySync(eslintConfigSrc, eslintConfigDest); + let eslintConfig = fs.readFileSync(eslintConfigSrc, "utf-8"); + + // Vendored third-party components (shadcn/ui, AI Elements) aren't written + // against the strict type-checked ruleset - skip linting them, like .next + const vendoredIgnores: string[] = []; + if (packages?.shadcn.inUse) vendoredIgnores.push("src/components/ui"); + if (packages?.aiElements.inUse) + vendoredIgnores.push("src/components/ai-elements"); + if (vendoredIgnores.length > 0) { + eslintConfig = eslintConfig.replace( + "ignores: ['.next']", + `ignores: ['.next', ${vendoredIgnores.map((i) => `'${i}'`).join(", ")}]` + ); + } + + fs.writeFileSync(eslintConfigDest, eslintConfig); }; diff --git a/cli/src/installers/index.ts b/cli/src/installers/index.ts index 70dc526..f5215cd 100644 --- a/cli/src/installers/index.ts +++ b/cli/src/installers/index.ts @@ -4,21 +4,29 @@ import { prismaInstaller } from "~/installers/prisma.js"; import { tailwindInstaller } from "~/installers/tailwind.js"; import { trpcInstaller } from "~/installers/trpc.js"; import { type PackageManager } from "~/utils/getUserPkgManager.js"; +import { aiElementsInstaller } from "./aiElements.js"; import { betterAuthInstaller } from "./betterAuth.js"; import { biomeInstaller } from "./biome.js"; +import { clerkInstaller } from "./clerk.js"; import { dbContainerInstaller } from "./dbContainer.js"; import { drizzleInstaller } from "./drizzle.js"; import { dynamicEslintInstaller } from "./eslint.js"; +import { shadcnInstaller } from "./shadcn.js"; +import { trelentInstaller } from "./trelent.js"; // Turning this into a const allows the list to be iterated over for programmatically creating prompt options // Should increase extensibility in the future export const availablePackages = [ "nextAuth", "betterAuth", + "clerk", "prisma", "drizzle", "tailwind", + "shadcn", + "aiElements", "trpc", + "trelent", "envVariables", "eslint", "biome", @@ -43,6 +51,7 @@ export interface InstallerOptions { projectName: string; scopedAppName: string; databaseProvider: DatabaseProvider; + sandboxName?: string; } export type Installer = (opts: InstallerOptions) => void; @@ -67,6 +76,10 @@ export const buildPkgInstallerMap = ( inUse: packages.includes("betterAuth"), installer: betterAuthInstaller, }, + clerk: { + inUse: packages.includes("clerk"), + installer: clerkInstaller, + }, prisma: { inUse: packages.includes("prisma"), installer: prismaInstaller, @@ -79,6 +92,14 @@ export const buildPkgInstallerMap = ( inUse: packages.includes("tailwind"), installer: tailwindInstaller, }, + shadcn: { + inUse: packages.includes("shadcn"), + installer: shadcnInstaller, + }, + aiElements: { + inUse: packages.includes("aiElements"), + installer: aiElementsInstaller, + }, trpc: { inUse: packages.includes("trpc"), installer: trpcInstaller, @@ -91,6 +112,12 @@ export const buildPkgInstallerMap = ( inUse: true, installer: envVariablesInstaller, }, + // Must run after envVariables: it appends the TRELENT_* vars to the + // generated src/env.js and .env files. + trelent: { + inUse: packages.includes("trelent"), + installer: trelentInstaller, + }, eslint: { inUse: packages.includes("eslint"), installer: dynamicEslintInstaller, diff --git a/cli/src/installers/prisma.ts b/cli/src/installers/prisma.ts index fdd4044..79d590b 100644 --- a/cli/src/installers/prisma.ts +++ b/cli/src/installers/prisma.ts @@ -30,11 +30,15 @@ export const prismaInstaller: Installer = ({ const extrasDir = path.join(PKG_ROOT, "template/extras"); + // The clerk schema ships the user-runs table for the Trelent runs + // experience; without trelent the example post router needs the base schema. const schemaBaseName = packages?.betterAuth.inUse ? "with-better-auth" : packages?.nextAuth.inUse ? "with-auth" - : "base"; + : packages?.clerk.inUse && packages?.trelent.inUse + ? "with-clerk" + : "base"; const schemaSrc = path.join( extrasDir, "prisma/schema", diff --git a/cli/src/installers/shadcn.ts b/cli/src/installers/shadcn.ts new file mode 100644 index 0000000..286c482 --- /dev/null +++ b/cli/src/installers/shadcn.ts @@ -0,0 +1,53 @@ +import path from "path"; +import fs from "fs-extra"; + +import { PKG_ROOT } from "~/consts.js"; +import { type Installer } from "~/installers/index.js"; +import { addPackageDependency } from "~/utils/addPackageDependency.js"; + +/** + * Sets up shadcn/ui non-interactively: instead of shelling out to + * `npx shadcn init` (which prompts), we ship the equivalent output as + * templates - components.json, the cn() helper, the CSS variable theme, and a + * vendored set of ui components. `npx shadcn add ` works as usual + * afterwards. + */ +export const shadcnInstaller: Installer = ({ projectDir }) => { + addPackageDependency({ + projectDir, + dependencies: [ + "radix-ui", + "class-variance-authority", + "clsx", + "tailwind-merge", + "lucide-react", + "cmdk", + ], + devMode: false, + }); + addPackageDependency({ + projectDir, + dependencies: ["tw-animate-css"], + devMode: true, + }); + + const extrasDir = path.join(PKG_ROOT, "template/extras"); + + fs.copySync( + path.join(extrasDir, "config/components.json"), + path.join(projectDir, "components.json") + ); + fs.copySync( + path.join(extrasDir, "src/lib/utils.ts"), + path.join(projectDir, "src/lib/utils.ts") + ); + fs.copySync( + path.join(extrasDir, "src/components/ui"), + path.join(projectDir, "src/components/ui") + ); + // Replace the plain Tailwind globals with the shadcn theme + fs.copySync( + path.join(extrasDir, "src/styles/globals-shadcn.css"), + path.join(projectDir, "src/styles/globals.css") + ); +}; diff --git a/cli/src/installers/trelent.ts b/cli/src/installers/trelent.ts new file mode 100644 index 0000000..b173da7 --- /dev/null +++ b/cli/src/installers/trelent.ts @@ -0,0 +1,77 @@ +import path from "path"; +import fs from "fs-extra"; + +import { PKG_ROOT } from "~/consts.js"; +import { type Installer } from "~/installers/index.js"; +import { addPackageDependency } from "~/utils/addPackageDependency.js"; + +const TRELENT_ENV_SERVER_VARS = (sandboxTag: string) => ` TRELENT_API_URL: z.string().url().default("http://localhost:8000"), + TRELENT_CLIENT_ID: z.string().optional(), + TRELENT_CLIENT_SECRET: z.string().optional(), + TRELENT_SANDBOX: z.string().default("${sandboxTag}"),`; + +const TRELENT_ENV_RUNTIME_VARS = ` TRELENT_API_URL: process.env.TRELENT_API_URL, + TRELENT_CLIENT_ID: process.env.TRELENT_CLIENT_ID, + TRELENT_CLIENT_SECRET: process.env.TRELENT_CLIENT_SECRET, + TRELENT_SANDBOX: process.env.TRELENT_SANDBOX,`; + +const TRELENT_DOTENV = (sandboxTag: string) => ` +# Trelent Agent Orchestrator +# https://www.npmjs.com/package/@trelent/agents +TRELENT_API_URL="http://localhost:8000" +TRELENT_CLIENT_ID="" +TRELENT_CLIENT_SECRET="" +TRELENT_SANDBOX="${sandboxTag}" +`; + +export const trelentInstaller: Installer = ({ projectDir, sandboxName }) => { + const name = sandboxName ?? "my-sandbox"; + const sandboxTag = `${name}:latest`; + + addPackageDependency({ + projectDir, + dependencies: ["@trelent/agents"], + devMode: false, + }); + + const extrasDir = path.join(PKG_ROOT, "template/extras"); + + // The sandbox definition lives at the project root, next to web/ + const rootDir = path.dirname(projectDir); + const sandboxDest = path.join(rootDir, "sandboxes", name); + fs.copySync(path.join(extrasDir, "sandboxes/default"), sandboxDest); + for (const file of ["Dockerfile", "README.md"]) { + const filePath = path.join(sandboxDest, file); + fs.writeFileSync( + filePath, + fs.readFileSync(filePath, "utf-8").replaceAll("my-sandbox", name) + ); + } + + // Server-side singleton client for the orchestrator + fs.copySync( + path.join(extrasDir, "src/server/trelent.ts"), + path.join(projectDir, "src/server/trelent.ts") + ); + + // Add the TRELENT_* vars to the env schema. This runs after the + // envVariables installer, so src/env.js and the .env files exist. + const envSchemaPath = path.join(projectDir, "src/env.js"); + let envSchema = fs.readFileSync(envSchemaPath, "utf-8"); + envSchema = envSchema.replace( + " server: {", + ` server: {\n${TRELENT_ENV_SERVER_VARS(sandboxTag)}` + ); + envSchema = envSchema.replace( + " runtimeEnv: {", + ` runtimeEnv: {\n${TRELENT_ENV_RUNTIME_VARS}` + ); + fs.writeFileSync(envSchemaPath, envSchema); + + for (const file of [".env", ".env.example"]) { + const filePath = path.join(projectDir, file); + if (fs.existsSync(filePath)) { + fs.appendFileSync(filePath, TRELENT_DOTENV(sandboxTag)); + } + } +}; diff --git a/cli/src/installers/trpc.ts b/cli/src/installers/trpc.ts index 09490f7..886ec4f 100644 --- a/cli/src/installers/trpc.ts +++ b/cli/src/installers/trpc.ts @@ -24,9 +24,13 @@ export const trpcInstaller: Installer = ({ const usingAuth = packages?.nextAuth.inUse; const usingBetterAuth = packages?.betterAuth.inUse; + const usingClerk = packages?.clerk.inUse; const usingPrisma = packages?.prisma.inUse; const usingDrizzle = packages?.drizzle.inUse; const usingDb = usingPrisma === true || usingDrizzle === true; + // The full agent-runs experience: Clerk-scoped runs stored in the db and + // executed through the Trelent orchestrator. + const usingRuns = usingClerk && usingDb && packages?.trelent.inUse; const extrasDir = path.join(PKG_ROOT, "template/extras"); @@ -40,6 +44,8 @@ export const trpcInstaller: Installer = ({ const trpcFile = (() => { if (usingBetterAuth && usingDb) return "with-better-auth-db.ts"; if (usingBetterAuth) return "with-better-auth.ts"; + if (usingClerk && usingDb) return "with-clerk-db.ts"; + if (usingClerk) return "with-clerk.ts"; if (usingAuth && usingDb) return "with-auth-db.ts"; if (usingAuth) return "with-auth.ts"; if (usingDb) return "with-db.ts"; @@ -53,7 +59,11 @@ export const trpcInstaller: Installer = ({ ); const trpcDest = path.join(projectDir, "src/server/api/trpc.ts"); - const rootRouterSrc = path.join(extrasDir, "src/server/api/root.ts"); + const rootRouterSrc = path.join( + extrasDir, + "src/server/api", + usingRuns ? "root-with-run.ts" : "root.ts" + ); const rootRouterDest = path.join(projectDir, "src/server/api/root.ts"); const exampleRouterFile = @@ -69,15 +79,16 @@ export const trpcInstaller: Installer = ({ ? "with-drizzle.ts" : "base.ts"; - const exampleRouterSrc = path.join( - extrasDir, - "src/server/api/routers/post", - exampleRouterFile - ); - const exampleRouterDest = path.join( - projectDir, - "src/server/api/routers/post.ts" - ); + const exampleRouterSrc = usingRuns + ? path.join( + extrasDir, + "src/server/api/routers/run", + usingPrisma ? "with-clerk-prisma.ts" : "with-clerk-drizzle.ts" + ) + : path.join(extrasDir, "src/server/api/routers/post", exampleRouterFile); + const exampleRouterDest = usingRuns + ? path.join(projectDir, "src/server/api/routers/run.ts") + : path.join(projectDir, "src/server/api/routers/post.ts"); const copySrcDest: [string, string][] = [ [apiHandlerSrc, apiHandlerDest], @@ -104,18 +115,23 @@ export const trpcInstaller: Installer = ({ path.join(projectDir, "src/trpc/react.tsx"), ], [ + path.join(extrasDir, "src/trpc/query-client.ts"), + path.join(projectDir, "src/trpc/query-client.ts"), + ] + ); + + // The runs experience ships its own components (copied with the page + // boilerplate); everything else gets the example post component. + if (!usingRuns) { + copySrcDest.push([ path.join( extrasDir, "src/app/_components", packages?.tailwind.inUse ? "post-tw.tsx" : "post.tsx" ), path.join(projectDir, "src/app/_components/post.tsx"), - ], - [ - path.join(extrasDir, "src/trpc/query-client.ts"), - path.join(projectDir, "src/trpc/query-client.ts"), - ] - ); + ]); + } } else { addPackageDependency({ dependencies: ["@trpc/next"], diff --git a/cli/template/extras/config/components.json b/cli/template/extras/config/components.json new file mode 100644 index 0000000..cee5a65 --- /dev/null +++ b/cli/template/extras/config/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "~/components", + "utils": "~/lib/utils", + "ui": "~/components/ui", + "lib": "~/lib", + "hooks": "~/hooks" + }, + "registries": { + "@ai-elements": "https://registry.ai-sdk.dev/{name}.json" + } +} diff --git a/cli/template/extras/prisma/schema/with-clerk-planetscale.prisma b/cli/template/extras/prisma/schema/with-clerk-planetscale.prisma new file mode 100644 index 0000000..138f0f0 --- /dev/null +++ b/cli/template/extras/prisma/schema/with-clerk-planetscale.prisma @@ -0,0 +1,41 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" + output = "../generated/prisma" + + previewFeatures = ["driverAdapters"] +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") + + // If you have enabled foreign key constraints for your database, remove this line. + relationMode = "prisma" +} + +/// Agent runs executed through the Trelent orchestrator. Each row belongs to a +/// Clerk user (`userId`); a forked run points at its parent via `parentId`, so +/// a run's ancestry chain forms the conversation thread. +model Run { + id String @id @default(uuid()) + userId String + parentId String? + parent Run? @relation("RunForks", fields: [parentId], references: [id]) + forks Run[] @relation("RunForks") + trelentRunId String? + sandbox String + harness String + model String? + prompt String + response String? + status String @default("pending") + error String? + createdAt DateTime @default(now()) + completedAt DateTime? + + @@index([userId]) + @@index([parentId]) +} diff --git a/cli/template/extras/prisma/schema/with-clerk.prisma b/cli/template/extras/prisma/schema/with-clerk.prisma new file mode 100644 index 0000000..65d5b72 --- /dev/null +++ b/cli/template/extras/prisma/schema/with-clerk.prisma @@ -0,0 +1,36 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" + output = "../generated/prisma" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +/// Agent runs executed through the Trelent orchestrator. Each row belongs to a +/// Clerk user (`userId`); a forked run points at its parent via `parentId`, so +/// a run's ancestry chain forms the conversation thread. +model Run { + id String @id @default(uuid()) + userId String + parentId String? + parent Run? @relation("RunForks", fields: [parentId], references: [id]) + forks Run[] @relation("RunForks") + trelentRunId String? + sandbox String + harness String + model String? + prompt String + response String? + status String @default("pending") + error String? + createdAt DateTime @default(now()) + completedAt DateTime? + + @@index([userId]) + @@index([parentId]) +} diff --git a/cli/template/extras/sandboxes/default/Dockerfile b/cli/template/extras/sandboxes/default/Dockerfile new file mode 100644 index 0000000..8ebc77a --- /dev/null +++ b/cli/template/extras/sandboxes/default/Dockerfile @@ -0,0 +1,20 @@ +# Sandbox image for the Trelent agent orchestrator. +# +# The orchestrator runs your chosen agent harness (Claude Code, Codex, ...) +# inside a container built from this image. Add any tools, languages, or +# project files your agents need. +# +# Build it: +# docker build -t my-sandbox:latest . +# +# When auth is enabled, push it to your namespace on the Trelent registry: +# docker login -u $TRELENT_CLIENT_ID -p $TRELENT_CLIENT_SECRET +# docker tag my-sandbox:latest /$TRELENT_CLIENT_ID/my-sandbox:latest +# docker push /$TRELENT_CLIENT_ID/my-sandbox:latest +FROM python:3.12-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl git \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace diff --git a/cli/template/extras/sandboxes/default/README.md b/cli/template/extras/sandboxes/default/README.md new file mode 100644 index 0000000..203eee6 --- /dev/null +++ b/cli/template/extras/sandboxes/default/README.md @@ -0,0 +1,22 @@ +# my-sandbox + +A sandbox image for the [Trelent agent orchestrator](https://www.npmjs.com/package/@trelent/agents). +Agents launched from your app run inside containers built from this image — +this one ships with Python 3.12, git, and curl. + +## Build + +```bash +docker build -t my-sandbox:latest . +``` + +## Publish (when your orchestrator has auth enabled) + +```bash +docker login -u "$TRELENT_CLIENT_ID" -p "$TRELENT_CLIENT_SECRET" +docker tag my-sandbox:latest /"$TRELENT_CLIENT_ID"/my-sandbox:latest +docker push /"$TRELENT_CLIENT_ID"/my-sandbox:latest +``` + +The web app references this sandbox through the `TRELENT_SANDBOX` environment +variable (see `web/.env`), so runs created from the UI execute here. diff --git a/cli/template/extras/src/app/_components/create-run.tsx b/cli/template/extras/src/app/_components/create-run.tsx new file mode 100644 index 0000000..b353b85 --- /dev/null +++ b/cli/template/extras/src/app/_components/create-run.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + type PromptInputMessage, + PromptInputSelect, + PromptInputSelectContent, + PromptInputSelectItem, + PromptInputSelectTrigger, + PromptInputSelectValue, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, +} from "~/components/ai-elements/prompt-input"; +import { + AGENT_MODELS, + DEFAULT_AGENT_MODEL_ID, + DEFAULT_PROMPT, +} from "~/lib/models"; +import { api } from "~/trpc/react"; + +export function CreateRun() { + const router = useRouter(); + const utils = api.useUtils(); + const [modelId, setModelId] = useState(DEFAULT_AGENT_MODEL_ID); + + const createRun = api.run.create.useMutation({ + onSuccess: (run) => { + // Seed the run page's cache before navigating so it paints instantly, + // with no loading state or flicker. + utils.run.get.setData({ id: run.id }, { run, thread: [run] }); + void utils.run.list.invalidate(); + router.push(`/runs/${run.id}`); + }, + }); + + const handleSubmit = (message: PromptInputMessage) => { + const text = message.text.trim(); + if (!text || createRun.isPending) return; + const model = AGENT_MODELS.find((m) => m.id === modelId); + createRun.mutate({ + prompt: text, + harness: model?.harness ?? "claude_code", + model: model?.model, + }); + }; + + return ( +
+ + + + + + + + + + + + {AGENT_MODELS.map((model) => ( + + {model.label} + + ))} + + + + + + + {createRun.error ? ( +

{createRun.error.message}

+ ) : null} +
+ ); +} diff --git a/cli/template/extras/src/app/_components/run-list.tsx b/cli/template/extras/src/app/_components/run-list.tsx new file mode 100644 index 0000000..a05cdbd --- /dev/null +++ b/cli/template/extras/src/app/_components/run-list.tsx @@ -0,0 +1,41 @@ +"use client"; + +import Link from "next/link"; + +import { RunStatusBadge } from "~/app/_components/run-status-badge"; +import { Card, CardContent } from "~/components/ui/card"; +import { agentModelLabel } from "~/lib/models"; +import { api } from "~/trpc/react"; + +export function RunList() { + const { data: runs } = api.run.list.useQuery(); + + if (!runs?.length) return null; + + return ( +
+

+ Recent runs +

+
+ {runs.map((run) => ( + + + +
+

{run.prompt}

+

+ {agentModelLabel(run.harness, run.model)} ·{" "} + {run.createdAt.toLocaleString()} + {run.parentId ? " · fork" : ""} +

+
+ +
+
+ + ))} +
+
+ ); +} diff --git a/cli/template/extras/src/app/_components/run-status-badge.tsx b/cli/template/extras/src/app/_components/run-status-badge.tsx new file mode 100644 index 0000000..86f76bc --- /dev/null +++ b/cli/template/extras/src/app/_components/run-status-badge.tsx @@ -0,0 +1,23 @@ +import { Badge } from "~/components/ui/badge"; +import { cn } from "~/lib/utils"; + +const STATUS_STYLES: Record = { + pending: "bg-muted text-muted-foreground", + starting: "bg-blue-500/15 text-blue-700 dark:text-blue-400", + running: "bg-blue-500/15 text-blue-700 dark:text-blue-400", + completed: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400", + failed: "bg-destructive/15 text-destructive", + timeout: "bg-destructive/15 text-destructive", + cancelled: "bg-muted text-muted-foreground", +}; + +export function RunStatusBadge({ status }: { status: string }) { + return ( + + {status} + + ); +} diff --git a/cli/template/extras/src/app/_components/run-thread.tsx b/cli/template/extras/src/app/_components/run-thread.tsx new file mode 100644 index 0000000..3a9d736 --- /dev/null +++ b/cli/template/extras/src/app/_components/run-thread.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { ArrowLeftIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { + Conversation, + ConversationContent, + ConversationScrollButton, +} from "~/components/ai-elements/conversation"; +import { Loader } from "~/components/ai-elements/loader"; +import { + Message, + MessageContent, + MessageResponse, +} from "~/components/ai-elements/message"; +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + type PromptInputMessage, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, +} from "~/components/ai-elements/prompt-input"; +import { RunStatusBadge } from "~/app/_components/run-status-badge"; +import { Button } from "~/components/ui/button"; +import { agentModelLabel } from "~/lib/models"; +import { api, type RouterOutputs } from "~/trpc/react"; + +type RunData = RouterOutputs["run"]["get"]["run"]; + +const TERMINAL_STATUSES = new Set([ + "completed", + "failed", + "timeout", + "cancelled", +]); + +const IN_FLIGHT_LABEL: Record = { + pending: "Waiting for the orchestrator…", + starting: "Starting your sandbox…", + running: "The agent is working…", +}; + +function AssistantReply({ run }: { run: RunData }) { + if (run.response) { + return {run.response}; + } + if (run.status === "failed" || run.status === "timeout") { + return ( +

+ {run.error ?? `This run ${run.status === "timeout" ? "timed out" : "failed"}.`} +

+ ); + } + if (run.status === "cancelled") { + return

This run was cancelled.

; + } + if (run.status === "completed") { + return ( +

+ The run completed without any output. +

+ ); + } + return ( +
+ + {IN_FLIGHT_LABEL[run.status] ?? "Working…"} +
+ ); +} + +export function RunThread({ id }: { id: string }) { + const router = useRouter(); + const utils = api.useUtils(); + const [pendingReply, setPendingReply] = useState(null); + + const { data, error } = api.run.get.useQuery( + { id }, + { + refetchInterval: (query) => { + const status = query.state.data?.run.status; + return status && TERMINAL_STATUSES.has(status) ? false : 2000; + }, + } + ); + + const fork = api.run.fork.useMutation({ + onSuccess: (run) => { + // Seed the forked run's cache with the thread we are already showing, + // then navigate. The new page paints the exact same conversation (plus + // the in-flight reply), so the transition is seamless - it feels like + // the agent simply starts responding. + utils.run.get.setData( + { id: run.id }, + { run, thread: [...(data?.thread ?? []), run] } + ); + void utils.run.list.invalidate(); + router.push(`/runs/${run.id}`); + }, + onError: () => setPendingReply(null), + }); + + const handleReply = (message: PromptInputMessage) => { + const text = message.text.trim(); + if (!text || fork.isPending) return; + setPendingReply(text); + fork.mutate({ runId: id, prompt: text }); + }; + + if (error) { + return ( +
+

{error.message}

+ +
+ ); + } + + const thread = data?.thread ?? []; + const leaf = data?.run; + const canReply = + !!leaf && TERMINAL_STATUSES.has(leaf.status) && !fork.isPending; + + return ( +
+
+
+ +
+

+ {thread[0]?.prompt ?? "Run"} +

+ {leaf ? ( +

+ {agentModelLabel(leaf.harness, leaf.model)} +

+ ) : null} +
+
+ {leaf ? : null} +
+ + + + {thread.map((run) => ( +
+ + {run.prompt} + + + + + + +
+ ))} + {pendingReply ? ( +
+ + {pendingReply} + + + +
+ + Forking this run… +
+
+
+
+ ) : null} +
+ +
+ +
+ + + + + + + + + + {fork.error ? ( +

{fork.error.message}

+ ) : null} +
+
+ ); +} diff --git a/cli/template/extras/src/app/layout/with-clerk-trpc.tsx b/cli/template/extras/src/app/layout/with-clerk-trpc.tsx new file mode 100644 index 0000000..d24a0b1 --- /dev/null +++ b/cli/template/extras/src/app/layout/with-clerk-trpc.tsx @@ -0,0 +1,32 @@ +import "~/styles/globals.css"; + +import { ClerkProvider } from "@clerk/nextjs"; +import { type Metadata } from "next"; +import { Geist } from "next/font/google"; + +import { TRPCReactProvider } from "~/trpc/react"; + +export const metadata: Metadata = { + title: "Create Agent App", + description: "Generated by create-agent-app", + icons: [{ rel: "icon", url: "/favicon.ico" }], +}; + +const geist = Geist({ + subsets: ["latin"], + variable: "--font-geist-sans", +}); + +export default function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + + + + {children} + + + + ); +} diff --git a/cli/template/extras/src/app/layout/with-clerk.tsx b/cli/template/extras/src/app/layout/with-clerk.tsx new file mode 100644 index 0000000..5481f0b --- /dev/null +++ b/cli/template/extras/src/app/layout/with-clerk.tsx @@ -0,0 +1,28 @@ +import "~/styles/globals.css"; + +import { ClerkProvider } from "@clerk/nextjs"; +import { type Metadata } from "next"; +import { Geist } from "next/font/google"; + +export const metadata: Metadata = { + title: "Create Agent App", + description: "Generated by create-agent-app", + icons: [{ rel: "icon", url: "/favicon.ico" }], +}; + +const geist = Geist({ + subsets: ["latin"], + variable: "--font-geist-sans", +}); + +export default function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + + + {children} + + + ); +} diff --git a/cli/template/extras/src/app/page/with-clerk.tsx b/cli/template/extras/src/app/page/with-clerk.tsx new file mode 100644 index 0000000..b31e3ac --- /dev/null +++ b/cli/template/extras/src/app/page/with-clerk.tsx @@ -0,0 +1,68 @@ +import { Show, SignInButton, SignOutButton } from "@clerk/nextjs"; +import { currentUser } from "@clerk/nextjs/server"; +import Link from "next/link"; + +export default async function Home() { + const user = await currentUser(); + + return ( +
+
+

+ Create Agent App +

+
+ +

First Steps →

+
+ Just the basics - Everything you need to know to set up your + database and authentication. +
+ + +

Clerk Docs →

+
+ Learn how to customize authentication, manage users, and go to + production with Clerk. +
+ +
+
+

+ {user ? ( + + Logged in as {user.firstName ?? user.username ?? "you"} + + ) : ( + You are not signed in + )} +

+ + + + } + > + + + + +
+
+
+ ); +} diff --git a/cli/template/extras/src/app/page/with-runs.tsx b/cli/template/extras/src/app/page/with-runs.tsx new file mode 100644 index 0000000..8f7b1ca --- /dev/null +++ b/cli/template/extras/src/app/page/with-runs.tsx @@ -0,0 +1,69 @@ +import { Show, SignInButton, UserButton } from "@clerk/nextjs"; +import { auth } from "@clerk/nextjs/server"; + +import { CreateRun } from "~/app/_components/create-run"; +import { RunList } from "~/app/_components/run-list"; +import { Button } from "~/components/ui/button"; +import { api, HydrateClient } from "~/trpc/server"; + +export default async function Home() { + const { userId } = await auth(); + if (userId) { + void api.run.list.prefetch(); + } + + return ( + +
+
+
+ + Agent Console + + + + + } + > + + +
+
+ +
+ +
+

+ Run agents in your sandbox +

+

+ Give an agent a task, watch it execute inside your Trelent + sandbox, and fork any run to keep the conversation going. +

+ + + +
+
+ + +
+

+ Start a run +

+

+ Pick a model and give the agent a task. It executes inside your + sandbox via the Trelent orchestrator. +

+
+ + +
+
+
+
+ ); +} diff --git a/cli/template/extras/src/app/runs/[id]/page.tsx b/cli/template/extras/src/app/runs/[id]/page.tsx new file mode 100644 index 0000000..71649f8 --- /dev/null +++ b/cli/template/extras/src/app/runs/[id]/page.tsx @@ -0,0 +1,17 @@ +import { RunThread } from "~/app/_components/run-thread"; +import { api, HydrateClient } from "~/trpc/server"; + +export default async function RunPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + void api.run.get.prefetch({ id }); + + return ( + + + + ); +} diff --git a/cli/template/extras/src/components/ai-elements/conversation.tsx b/cli/template/extras/src/components/ai-elements/conversation.tsx new file mode 100644 index 0000000..14cbe88 --- /dev/null +++ b/cli/template/extras/src/components/ai-elements/conversation.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; +import { ArrowDownIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { useCallback } from "react"; +import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; + +export type ConversationProps = ComponentProps; + +export const Conversation = ({ className, ...props }: ConversationProps) => ( + +); + +export type ConversationContentProps = ComponentProps< + typeof StickToBottom.Content +>; + +export const ConversationContent = ({ + className, + ...props +}: ConversationContentProps) => ( + +); + +export type ConversationEmptyStateProps = ComponentProps<"div"> & { + title?: string; + description?: string; + icon?: React.ReactNode; +}; + +export const ConversationEmptyState = ({ + className, + title = "No messages yet", + description = "Start a conversation to see messages here", + icon, + children, + ...props +}: ConversationEmptyStateProps) => ( +
+ {children ?? ( + <> + {icon &&
{icon}
} +
+

{title}

+ {description && ( +

{description}

+ )} +
+ + )} +
+); + +export type ConversationScrollButtonProps = ComponentProps; + +export const ConversationScrollButton = ({ + className, + ...props +}: ConversationScrollButtonProps) => { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + const handleScrollToBottom = useCallback(() => { + scrollToBottom(); + }, [scrollToBottom]); + + return ( + !isAtBottom && ( + + ) + ); +}; diff --git a/cli/template/extras/src/components/ai-elements/loader.tsx b/cli/template/extras/src/components/ai-elements/loader.tsx new file mode 100644 index 0000000..1a8301d --- /dev/null +++ b/cli/template/extras/src/components/ai-elements/loader.tsx @@ -0,0 +1,96 @@ +import { cn } from "~/lib/utils"; +import type { HTMLAttributes } from "react"; + +type LoaderIconProps = { + size?: number; +}; + +const LoaderIcon = ({ size = 16 }: LoaderIconProps) => ( + + Loader + + + + + + + + + + + + + + + + + + +); + +export type LoaderProps = HTMLAttributes & { + size?: number; +}; + +export const Loader = ({ className, size = 16, ...props }: LoaderProps) => ( +
+ +
+); diff --git a/cli/template/extras/src/components/ai-elements/message.tsx b/cli/template/extras/src/components/ai-elements/message.tsx new file mode 100644 index 0000000..c394e89 --- /dev/null +++ b/cli/template/extras/src/components/ai-elements/message.tsx @@ -0,0 +1,448 @@ +"use client"; + +import { Button } from "~/components/ui/button"; +import { + ButtonGroup, + ButtonGroupText, +} from "~/components/ui/button-group"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/ui/tooltip"; +import { cn } from "~/lib/utils"; +import type { FileUIPart, UIMessage } from "ai"; +import { + ChevronLeftIcon, + ChevronRightIcon, + PaperclipIcon, + XIcon, +} from "lucide-react"; +import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; +import { createContext, memo, useContext, useEffect, useState } from "react"; +import { Streamdown } from "streamdown"; + +export type MessageProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const Message = ({ className, from, ...props }: MessageProps) => ( +
+); + +export type MessageContentProps = HTMLAttributes; + +export const MessageContent = ({ + children, + className, + ...props +}: MessageContentProps) => ( +
+ {children} +
+); + +export type MessageActionsProps = ComponentProps<"div">; + +export const MessageActions = ({ + className, + children, + ...props +}: MessageActionsProps) => ( +
+ {children} +
+); + +export type MessageActionProps = ComponentProps & { + tooltip?: string; + label?: string; +}; + +export const MessageAction = ({ + tooltip, + children, + label, + variant = "ghost", + size = "icon-sm", + ...props +}: MessageActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +type MessageBranchContextType = { + currentBranch: number; + totalBranches: number; + goToPrevious: () => void; + goToNext: () => void; + branches: ReactElement[]; + setBranches: (branches: ReactElement[]) => void; +}; + +const MessageBranchContext = createContext( + null +); + +const useMessageBranch = () => { + const context = useContext(MessageBranchContext); + + if (!context) { + throw new Error( + "MessageBranch components must be used within MessageBranch" + ); + } + + return context; +}; + +export type MessageBranchProps = HTMLAttributes & { + defaultBranch?: number; + onBranchChange?: (branchIndex: number) => void; +}; + +export const MessageBranch = ({ + defaultBranch = 0, + onBranchChange, + className, + ...props +}: MessageBranchProps) => { + const [currentBranch, setCurrentBranch] = useState(defaultBranch); + const [branches, setBranches] = useState([]); + + const handleBranchChange = (newBranch: number) => { + setCurrentBranch(newBranch); + onBranchChange?.(newBranch); + }; + + const goToPrevious = () => { + const newBranch = + currentBranch > 0 ? currentBranch - 1 : branches.length - 1; + handleBranchChange(newBranch); + }; + + const goToNext = () => { + const newBranch = + currentBranch < branches.length - 1 ? currentBranch + 1 : 0; + handleBranchChange(newBranch); + }; + + const contextValue: MessageBranchContextType = { + currentBranch, + totalBranches: branches.length, + goToPrevious, + goToNext, + branches, + setBranches, + }; + + return ( + +
div]:pb-0", className)} + {...props} + /> + + ); +}; + +export type MessageBranchContentProps = HTMLAttributes; + +export const MessageBranchContent = ({ + children, + ...props +}: MessageBranchContentProps) => { + const { currentBranch, setBranches, branches } = useMessageBranch(); + const childrenArray = Array.isArray(children) ? children : [children]; + + // Use useEffect to update branches when they change + useEffect(() => { + if (branches.length !== childrenArray.length) { + setBranches(childrenArray); + } + }, [childrenArray, branches, setBranches]); + + return childrenArray.map((branch, index) => ( +
div]:pb-0", + index === currentBranch ? "block" : "hidden" + )} + key={branch.key} + {...props} + > + {branch} +
+ )); +}; + +export type MessageBranchSelectorProps = HTMLAttributes & { + from: UIMessage["role"]; +}; + +export const MessageBranchSelector = ({ + className, + from, + ...props +}: MessageBranchSelectorProps) => { + const { totalBranches } = useMessageBranch(); + + // Don't render if there's only one branch + if (totalBranches <= 1) { + return null; + } + + return ( + + ); +}; + +export type MessageBranchPreviousProps = ComponentProps; + +export const MessageBranchPrevious = ({ + children, + ...props +}: MessageBranchPreviousProps) => { + const { goToPrevious, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchNextProps = ComponentProps; + +export const MessageBranchNext = ({ + children, + className, + ...props +}: MessageBranchNextProps) => { + const { goToNext, totalBranches } = useMessageBranch(); + + return ( + + ); +}; + +export type MessageBranchPageProps = HTMLAttributes; + +export const MessageBranchPage = ({ + className, + ...props +}: MessageBranchPageProps) => { + const { currentBranch, totalBranches } = useMessageBranch(); + + return ( + + {currentBranch + 1} of {totalBranches} + + ); +}; + +export type MessageResponseProps = ComponentProps; + +export const MessageResponse = memo( + ({ className, ...props }: MessageResponseProps) => ( + *:first-child]:mt-0 [&>*:last-child]:mb-0", + className + )} + {...props} + /> + ), + (prevProps, nextProps) => prevProps.children === nextProps.children +); + +MessageResponse.displayName = "MessageResponse"; + +export type MessageAttachmentProps = HTMLAttributes & { + data: FileUIPart; + className?: string; + onRemove?: () => void; +}; + +export function MessageAttachment({ + data, + className, + onRemove, + ...props +}: MessageAttachmentProps) { + const filename = data.filename || ""; + const mediaType = + data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; + const isImage = mediaType === "image"; + const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); + + return ( +
+ {isImage ? ( + <> + {filename + {onRemove && ( + + )} + + ) : ( + <> + + +
+ +
+
+ +

{attachmentLabel}

+
+
+ {onRemove && ( + + )} + + )} +
+ ); +} + +export type MessageAttachmentsProps = ComponentProps<"div">; + +export function MessageAttachments({ + children, + className, + ...props +}: MessageAttachmentsProps) { + if (!children) { + return null; + } + + return ( +
+ {children} +
+ ); +} + +export type MessageToolbarProps = ComponentProps<"div">; + +export const MessageToolbar = ({ + className, + children, + ...props +}: MessageToolbarProps) => ( +
+ {children} +
+); diff --git a/cli/template/extras/src/components/ai-elements/prompt-input.tsx b/cli/template/extras/src/components/ai-elements/prompt-input.tsx new file mode 100644 index 0000000..6521791 --- /dev/null +++ b/cli/template/extras/src/components/ai-elements/prompt-input.tsx @@ -0,0 +1,1413 @@ +"use client"; + +import { Button } from "~/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "~/components/ui/command"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "~/components/ui/hover-card"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupTextarea, +} from "~/components/ui/input-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { cn } from "~/lib/utils"; +import type { ChatStatus, FileUIPart } from "ai"; +import { + CornerDownLeftIcon, + ImageIcon, + Loader2Icon, + MicIcon, + PaperclipIcon, + PlusIcon, + SquareIcon, + XIcon, +} from "lucide-react"; +import { nanoid } from "nanoid"; +import { + type ChangeEvent, + type ChangeEventHandler, + Children, + type ClipboardEventHandler, + type ComponentProps, + createContext, + type FormEvent, + type FormEventHandler, + Fragment, + type HTMLAttributes, + type KeyboardEventHandler, + type PropsWithChildren, + type ReactNode, + type RefObject, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +// ============================================================================ +// Provider Context & Types +// ============================================================================ + +export type AttachmentsContext = { + files: (FileUIPart & { id: string })[]; + add: (files: File[] | FileList) => void; + remove: (id: string) => void; + clear: () => void; + openFileDialog: () => void; + fileInputRef: RefObject; +}; + +export type TextInputContext = { + value: string; + setInput: (v: string) => void; + clear: () => void; +}; + +export type PromptInputControllerProps = { + textInput: TextInputContext; + attachments: AttachmentsContext; + /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ + __registerFileInput: ( + ref: RefObject, + open: () => void + ) => void; +}; + +const PromptInputController = createContext( + null +); +const ProviderAttachmentsContext = createContext( + null +); + +export const usePromptInputController = () => { + const ctx = useContext(PromptInputController); + if (!ctx) { + throw new Error( + "Wrap your component inside to use usePromptInputController()." + ); + } + return ctx; +}; + +// Optional variants (do NOT throw). Useful for dual-mode components. +const useOptionalPromptInputController = () => + useContext(PromptInputController); + +export const useProviderAttachments = () => { + const ctx = useContext(ProviderAttachmentsContext); + if (!ctx) { + throw new Error( + "Wrap your component inside to use useProviderAttachments()." + ); + } + return ctx; +}; + +const useOptionalProviderAttachments = () => + useContext(ProviderAttachmentsContext); + +export type PromptInputProviderProps = PropsWithChildren<{ + initialInput?: string; +}>; + +/** + * Optional global provider that lifts PromptInput state outside of PromptInput. + * If you don't use it, PromptInput stays fully self-managed. + */ +export function PromptInputProvider({ + initialInput: initialTextInput = "", + children, +}: PromptInputProviderProps) { + // ----- textInput state + const [textInput, setTextInput] = useState(initialTextInput); + const clearInput = useCallback(() => setTextInput(""), []); + + // ----- attachments state (global when wrapped) + const [attachmentFiles, setAttachmentFiles] = useState< + (FileUIPart & { id: string })[] + >([]); + const fileInputRef = useRef(null); + const openRef = useRef<() => void>(() => {}); + + const add = useCallback((files: File[] | FileList) => { + const incoming = Array.from(files); + if (incoming.length === 0) { + return; + } + + setAttachmentFiles((prev) => + prev.concat( + incoming.map((file) => ({ + id: nanoid(), + type: "file" as const, + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + })) + ) + ); + }, []); + + const remove = useCallback((id: string) => { + setAttachmentFiles((prev) => { + const found = prev.find((f) => f.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((f) => f.id !== id); + }); + }, []); + + const clear = useCallback(() => { + setAttachmentFiles((prev) => { + for (const f of prev) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + return []; + }); + }, []); + + // Keep a ref to attachments for cleanup on unmount (avoids stale closure) + const attachmentsRef = useRef(attachmentFiles); + attachmentsRef.current = attachmentFiles; + + // Cleanup blob URLs on unmount to prevent memory leaks + useEffect(() => { + return () => { + for (const f of attachmentsRef.current) { + if (f.url) { + URL.revokeObjectURL(f.url); + } + } + }; + }, []); + + const openFileDialog = useCallback(() => { + openRef.current?.(); + }, []); + + const attachments = useMemo( + () => ({ + files: attachmentFiles, + add, + remove, + clear, + openFileDialog, + fileInputRef, + }), + [attachmentFiles, add, remove, clear, openFileDialog] + ); + + const __registerFileInput = useCallback( + (ref: RefObject, open: () => void) => { + fileInputRef.current = ref.current; + openRef.current = open; + }, + [] + ); + + const controller = useMemo( + () => ({ + textInput: { + value: textInput, + setInput: setTextInput, + clear: clearInput, + }, + attachments, + __registerFileInput, + }), + [textInput, clearInput, attachments, __registerFileInput] + ); + + return ( + + + {children} + + + ); +} + +// ============================================================================ +// Component Context & Hooks +// ============================================================================ + +const LocalAttachmentsContext = createContext(null); + +export const usePromptInputAttachments = () => { + // Dual-mode: prefer provider if present, otherwise use local + const provider = useOptionalProviderAttachments(); + const local = useContext(LocalAttachmentsContext); + const context = provider ?? local; + if (!context) { + throw new Error( + "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider" + ); + } + return context; +}; + +export type PromptInputAttachmentProps = HTMLAttributes & { + data: FileUIPart & { id: string }; + className?: string; +}; + +export function PromptInputAttachment({ + data, + className, + ...props +}: PromptInputAttachmentProps) { + const attachments = usePromptInputAttachments(); + + const filename = data.filename || ""; + + const mediaType = + data.mediaType?.startsWith("image/") && data.url ? "image" : "file"; + const isImage = mediaType === "image"; + + const attachmentLabel = filename || (isImage ? "Image" : "Attachment"); + + return ( + + +
+
+
+ {isImage ? ( + {filename + ) : ( +
+ +
+ )} +
+ +
+ + {attachmentLabel} +
+
+ +
+ {isImage && ( +
+ {filename +
+ )} +
+
+

+ {filename || (isImage ? "Image" : "Attachment")} +

+ {data.mediaType && ( +

+ {data.mediaType} +

+ )} +
+
+
+
+
+ ); +} + +export type PromptInputAttachmentsProps = Omit< + HTMLAttributes, + "children" +> & { + children: (attachment: FileUIPart & { id: string }) => ReactNode; +}; + +export function PromptInputAttachments({ + children, + className, + ...props +}: PromptInputAttachmentsProps) { + const attachments = usePromptInputAttachments(); + + if (!attachments.files.length) { + return null; + } + + return ( +
+ {attachments.files.map((file) => ( + {children(file)} + ))} +
+ ); +} + +export type PromptInputActionAddAttachmentsProps = ComponentProps< + typeof DropdownMenuItem +> & { + label?: string; +}; + +export const PromptInputActionAddAttachments = ({ + label = "Add photos or files", + ...props +}: PromptInputActionAddAttachmentsProps) => { + const attachments = usePromptInputAttachments(); + + return ( + { + e.preventDefault(); + attachments.openFileDialog(); + }} + > + {label} + + ); +}; + +export type PromptInputMessage = { + text: string; + files: FileUIPart[]; +}; + +export type PromptInputProps = Omit< + HTMLAttributes, + "onSubmit" | "onError" +> & { + accept?: string; // e.g., "image/*" or leave undefined for any + multiple?: boolean; + // When true, accepts drops anywhere on document. Default false (opt-in). + globalDrop?: boolean; + // Render a hidden input with given name and keep it in sync for native form posts. Default false. + syncHiddenInput?: boolean; + // Minimal constraints + maxFiles?: number; + maxFileSize?: number; // bytes + onError?: (err: { + code: "max_files" | "max_file_size" | "accept"; + message: string; + }) => void; + onSubmit: ( + message: PromptInputMessage, + event: FormEvent + ) => void | Promise; +}; + +export const PromptInput = ({ + className, + accept, + multiple, + globalDrop, + syncHiddenInput, + maxFiles, + maxFileSize, + onError, + onSubmit, + children, + ...props +}: PromptInputProps) => { + // Try to use a provider controller if present + const controller = useOptionalPromptInputController(); + const usingProvider = !!controller; + + // Refs + const inputRef = useRef(null); + const formRef = useRef(null); + + // ----- Local attachments (only used when no provider) + const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const files = usingProvider ? controller.attachments.files : items; + + // Keep a ref to files for cleanup on unmount (avoids stale closure) + const filesRef = useRef(files); + filesRef.current = files; + + const openFileDialogLocal = useCallback(() => { + inputRef.current?.click(); + }, []); + + const matchesAccept = useCallback( + (f: File) => { + if (!accept || accept.trim() === "") { + return true; + } + + const patterns = accept + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + return patterns.some((pattern) => { + if (pattern.endsWith("/*")) { + const prefix = pattern.slice(0, -1); // e.g: image/* -> image/ + return f.type.startsWith(prefix); + } + return f.type === pattern; + }); + }, + [accept] + ); + + const addLocal = useCallback( + (fileList: File[] | FileList) => { + const incoming = Array.from(fileList); + const accepted = incoming.filter((f) => matchesAccept(f)); + if (incoming.length && accepted.length === 0) { + onError?.({ + code: "accept", + message: "No files match the accepted types.", + }); + return; + } + const withinSize = (f: File) => + maxFileSize ? f.size <= maxFileSize : true; + const sized = accepted.filter(withinSize); + if (accepted.length > 0 && sized.length === 0) { + onError?.({ + code: "max_file_size", + message: "All files exceed the maximum size.", + }); + return; + } + + setItems((prev) => { + const capacity = + typeof maxFiles === "number" + ? Math.max(0, maxFiles - prev.length) + : undefined; + const capped = + typeof capacity === "number" ? sized.slice(0, capacity) : sized; + if (typeof capacity === "number" && sized.length > capacity) { + onError?.({ + code: "max_files", + message: "Too many files. Some were not added.", + }); + } + const next: (FileUIPart & { id: string })[] = []; + for (const file of capped) { + next.push({ + id: nanoid(), + type: "file", + url: URL.createObjectURL(file), + mediaType: file.type, + filename: file.name, + }); + } + return prev.concat(next); + }); + }, + [matchesAccept, maxFiles, maxFileSize, onError] + ); + + const removeLocal = useCallback( + (id: string) => + setItems((prev) => { + const found = prev.find((file) => file.id === id); + if (found?.url) { + URL.revokeObjectURL(found.url); + } + return prev.filter((file) => file.id !== id); + }), + [] + ); + + const clearLocal = useCallback( + () => + setItems((prev) => { + for (const file of prev) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } + return []; + }), + [] + ); + + const add = usingProvider ? controller.attachments.add : addLocal; + const remove = usingProvider ? controller.attachments.remove : removeLocal; + const clear = usingProvider ? controller.attachments.clear : clearLocal; + const openFileDialog = usingProvider + ? controller.attachments.openFileDialog + : openFileDialogLocal; + + // Let provider know about our hidden file input so external menus can call openFileDialog() + useEffect(() => { + if (!usingProvider) return; + controller.__registerFileInput(inputRef, () => inputRef.current?.click()); + }, [usingProvider, controller]); + + // Note: File input cannot be programmatically set for security reasons + // The syncHiddenInput prop is no longer functional + useEffect(() => { + if (syncHiddenInput && inputRef.current && files.length === 0) { + inputRef.current.value = ""; + } + }, [files, syncHiddenInput]); + + // Attach drop handlers on nearest form and document (opt-in) + useEffect(() => { + const form = formRef.current; + if (!form) return; + if (globalDrop) return // when global drop is on, let the document-level handler own drops + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + form.addEventListener("dragover", onDragOver); + form.addEventListener("drop", onDrop); + return () => { + form.removeEventListener("dragover", onDragOver); + form.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect(() => { + if (!globalDrop) return; + + const onDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + }; + const onDrop = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) { + e.preventDefault(); + } + if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { + add(e.dataTransfer.files); + } + }; + document.addEventListener("dragover", onDragOver); + document.addEventListener("drop", onDrop); + return () => { + document.removeEventListener("dragover", onDragOver); + document.removeEventListener("drop", onDrop); + }; + }, [add, globalDrop]); + + useEffect( + () => () => { + if (!usingProvider) { + for (const f of filesRef.current) { + if (f.url) URL.revokeObjectURL(f.url); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current + [usingProvider] + ); + + const handleChange: ChangeEventHandler = (event) => { + if (event.currentTarget.files) { + add(event.currentTarget.files); + } + // Reset input value to allow selecting files that were previously removed + event.currentTarget.value = ""; + }; + + const convertBlobUrlToDataUrl = async ( + url: string + ): Promise => { + try { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = () => resolve(null); + reader.readAsDataURL(blob); + }); + } catch { + return null; + } + }; + + const ctx = useMemo( + () => ({ + files: files.map((item) => ({ ...item, id: item.id })), + add, + remove, + clear, + openFileDialog, + fileInputRef: inputRef, + }), + [files, add, remove, clear, openFileDialog] + ); + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault(); + + const form = event.currentTarget; + const text = usingProvider + ? controller.textInput.value + : (() => { + const formData = new FormData(form); + return (formData.get("message") as string) || ""; + })(); + + // Reset form immediately after capturing text to avoid race condition + // where user input during async blob conversion would be lost + if (!usingProvider) { + form.reset(); + } + + // Convert blob URLs to data URLs asynchronously + Promise.all( + files.map(async ({ id, ...item }) => { + if (item.url && item.url.startsWith("blob:")) { + const dataUrl = await convertBlobUrlToDataUrl(item.url); + // If conversion failed, keep the original blob URL + return { + ...item, + url: dataUrl ?? item.url, + }; + } + return item; + }) + ) + .then((convertedFiles: FileUIPart[]) => { + try { + const result = onSubmit({ text, files: convertedFiles }, event); + + // Handle both sync and async onSubmit + if (result instanceof Promise) { + result + .then(() => { + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + } else { + // Sync function completed without throwing, clear attachments + clear(); + if (usingProvider) { + controller.textInput.clear(); + } + } + } catch { + // Don't clear on error - user may want to retry + } + }) + .catch(() => { + // Don't clear on error - user may want to retry + }); + }; + + // Render with or without local provider + const inner = ( + <> + +
+ {children} +
+ + ); + + return usingProvider ? ( + inner + ) : ( + + {inner} + + ); +}; + +export type PromptInputBodyProps = HTMLAttributes; + +export const PromptInputBody = ({ + className, + ...props +}: PromptInputBodyProps) => ( +
+); + +export type PromptInputTextareaProps = ComponentProps< + typeof InputGroupTextarea +>; + +export const PromptInputTextarea = ({ + onChange, + className, + placeholder = "What would you like to know?", + ...props +}: PromptInputTextareaProps) => { + const controller = useOptionalPromptInputController(); + const attachments = usePromptInputAttachments(); + const [isComposing, setIsComposing] = useState(false); + + const handleKeyDown: KeyboardEventHandler = (e) => { + if (e.key === "Enter") { + if (isComposing || e.nativeEvent.isComposing) { + return; + } + if (e.shiftKey) { + return; + } + e.preventDefault(); + + // Check if the submit button is disabled before submitting + const form = e.currentTarget.form; + const submitButton = form?.querySelector( + 'button[type="submit"]' + ) as HTMLButtonElement | null; + if (submitButton?.disabled) { + return; + } + + form?.requestSubmit(); + } + + // Remove last attachment when Backspace is pressed and textarea is empty + if ( + e.key === "Backspace" && + e.currentTarget.value === "" && + attachments.files.length > 0 + ) { + e.preventDefault(); + const lastAttachment = attachments.files.at(-1); + if (lastAttachment) { + attachments.remove(lastAttachment.id); + } + } + }; + + const handlePaste: ClipboardEventHandler = (event) => { + const items = event.clipboardData?.items; + + if (!items) { + return; + } + + const files: File[] = []; + + for (const item of items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + if (files.length > 0) { + event.preventDefault(); + attachments.add(files); + } + }; + + const controlledProps = controller + ? { + value: controller.textInput.value, + onChange: (e: ChangeEvent) => { + controller.textInput.setInput(e.currentTarget.value); + onChange?.(e); + }, + } + : { + onChange, + }; + + return ( + setIsComposing(false)} + onCompositionStart={() => setIsComposing(true)} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + placeholder={placeholder} + {...props} + {...controlledProps} + /> + ); +}; + +export type PromptInputHeaderProps = Omit< + ComponentProps, + "align" +>; + +export const PromptInputHeader = ({ + className, + ...props +}: PromptInputHeaderProps) => ( + +); + +export type PromptInputFooterProps = Omit< + ComponentProps, + "align" +>; + +export const PromptInputFooter = ({ + className, + ...props +}: PromptInputFooterProps) => ( + +); + +export type PromptInputToolsProps = HTMLAttributes; + +export const PromptInputTools = ({ + className, + ...props +}: PromptInputToolsProps) => ( +
+); + +export type PromptInputButtonProps = ComponentProps; + +export const PromptInputButton = ({ + variant = "ghost", + className, + size, + ...props +}: PromptInputButtonProps) => { + const newSize = + size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm"); + + return ( + + ); +}; + +export type PromptInputActionMenuProps = ComponentProps; +export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ( + +); + +export type PromptInputActionMenuTriggerProps = PromptInputButtonProps; + +export const PromptInputActionMenuTrigger = ({ + className, + children, + ...props +}: PromptInputActionMenuTriggerProps) => ( + + + {children ?? } + + +); + +export type PromptInputActionMenuContentProps = ComponentProps< + typeof DropdownMenuContent +>; +export const PromptInputActionMenuContent = ({ + className, + ...props +}: PromptInputActionMenuContentProps) => ( + +); + +export type PromptInputActionMenuItemProps = ComponentProps< + typeof DropdownMenuItem +>; +export const PromptInputActionMenuItem = ({ + className, + ...props +}: PromptInputActionMenuItemProps) => ( + +); + +// Note: Actions that perform side-effects (like opening a file dialog) +// are provided in opt-in modules (e.g., prompt-input-attachments). + +export type PromptInputSubmitProps = ComponentProps & { + status?: ChatStatus; +}; + +export const PromptInputSubmit = ({ + className, + variant = "default", + size = "icon-sm", + status, + children, + ...props +}: PromptInputSubmitProps) => { + let Icon = ; + + if (status === "submitted") { + Icon = ; + } else if (status === "streaming") { + Icon = ; + } else if (status === "error") { + Icon = ; + } + + return ( + + {children ?? Icon} + + ); +}; + +interface SpeechRecognition extends EventTarget { + continuous: boolean; + interimResults: boolean; + lang: string; + start(): void; + stop(): void; + onstart: ((this: SpeechRecognition, ev: Event) => any) | null; + onend: ((this: SpeechRecognition, ev: Event) => any) | null; + onresult: + | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any) + | null; + onerror: + | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any) + | null; +} + +interface SpeechRecognitionEvent extends Event { + results: SpeechRecognitionResultList; + resultIndex: number; +} + +type SpeechRecognitionResultList = { + readonly length: number; + item(index: number): SpeechRecognitionResult; + [index: number]: SpeechRecognitionResult; +}; + +type SpeechRecognitionResult = { + readonly length: number; + item(index: number): SpeechRecognitionAlternative; + [index: number]: SpeechRecognitionAlternative; + isFinal: boolean; +}; + +type SpeechRecognitionAlternative = { + transcript: string; + confidence: number; +}; + +interface SpeechRecognitionErrorEvent extends Event { + error: string; +} + +declare global { + interface Window { + SpeechRecognition: { + new (): SpeechRecognition; + }; + webkitSpeechRecognition: { + new (): SpeechRecognition; + }; + } +} + +export type PromptInputSpeechButtonProps = ComponentProps< + typeof PromptInputButton +> & { + textareaRef?: RefObject; + onTranscriptionChange?: (text: string) => void; +}; + +export const PromptInputSpeechButton = ({ + className, + textareaRef, + onTranscriptionChange, + ...props +}: PromptInputSpeechButtonProps) => { + const [isListening, setIsListening] = useState(false); + const [recognition, setRecognition] = useState( + null + ); + const recognitionRef = useRef(null); + + useEffect(() => { + if ( + typeof window !== "undefined" && + ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) + ) { + const SpeechRecognition = + window.SpeechRecognition || window.webkitSpeechRecognition; + const speechRecognition = new SpeechRecognition(); + + speechRecognition.continuous = true; + speechRecognition.interimResults = true; + speechRecognition.lang = "en-US"; + + speechRecognition.onstart = () => { + setIsListening(true); + }; + + speechRecognition.onend = () => { + setIsListening(false); + }; + + speechRecognition.onresult = (event) => { + let finalTranscript = ""; + + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + if (result?.isFinal) { + finalTranscript += result[0]?.transcript ?? ""; + } + } + + if (finalTranscript && textareaRef?.current) { + const textarea = textareaRef.current; + const currentValue = textarea.value; + const newValue = + currentValue + (currentValue ? " " : "") + finalTranscript; + + textarea.value = newValue; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + onTranscriptionChange?.(newValue); + } + }; + + speechRecognition.onerror = (event) => { + console.error("Speech recognition error:", event.error); + setIsListening(false); + }; + + recognitionRef.current = speechRecognition; + setRecognition(speechRecognition); + } + + return () => { + if (recognitionRef.current) { + recognitionRef.current.stop(); + } + }; + }, [textareaRef, onTranscriptionChange]); + + const toggleListening = useCallback(() => { + if (!recognition) { + return; + } + + if (isListening) { + recognition.stop(); + } else { + recognition.start(); + } + }, [recognition, isListening]); + + return ( + + + + ); +}; + +export type PromptInputSelectProps = ComponentProps; + +export const PromptInputSelect = (props: PromptInputSelectProps) => ( + + ) +} + +function InputGroupTextarea({ + className, + ...props +}: React.ComponentProps<"textarea">) { + return ( +