diff --git a/apps/api/package.json b/apps/api/package.json index c7630a538..d46f9e4c9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,7 +11,7 @@ "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.1.5", "@nestjs/swagger": "^11.2.0", - "@trycompai/db": "^1.3.4", + "@trycompai/db": "^1.3.7", "archiver": "^7.0.1", "axios": "^1.12.2", "class-transformer": "^0.5.1", diff --git a/apps/api/src/comments/comments.service.ts b/apps/api/src/comments/comments.service.ts index ab706f1dc..7d245dd8b 100644 --- a/apps/api/src/comments/comments.service.ts +++ b/apps/api/src/comments/comments.service.ts @@ -115,6 +115,7 @@ export class CommentsService { id: comment.author.user.id, name: comment.author.user.name, email: comment.author.user.email, + image: comment.author.user.image, }, attachments, createdAt: comment.createdAt, @@ -209,6 +210,7 @@ export class CommentsService { id: member.user.id, name: member.user.name, email: member.user.email, + image: member.user.image, }, attachments: result.attachments, createdAt: result.comment.createdAt, @@ -281,6 +283,7 @@ export class CommentsService { id: existingComment.author.user.id, name: existingComment.author.user.name, email: existingComment.author.user.email, + image: existingComment.author.user.image, }, attachments, createdAt: updatedComment.createdAt, diff --git a/apps/api/src/comments/dto/comment-responses.dto.ts b/apps/api/src/comments/dto/comment-responses.dto.ts index c6de5ee15..e98f366ce 100644 --- a/apps/api/src/comments/dto/comment-responses.dto.ts +++ b/apps/api/src/comments/dto/comment-responses.dto.ts @@ -82,6 +82,13 @@ export class AuthorResponseDto { example: 'john.doe@company.com', }) email: string; + + @ApiProperty({ + description: 'User profile image URL', + example: 'https://example.com/avatar.jpg', + nullable: true, + }) + image: string | null; } export class CommentResponseDto { diff --git a/apps/app/.env.example b/apps/app/.env.example index 4ebce08b5..883e586e4 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -7,7 +7,7 @@ REVALIDATION_SECRET="" # openssl rand -base64 32 NEXT_PUBLIC_PORTAL_URL="http://localhost:3002" # The employee portal uses port 3002 by default # Recommended -# Store attachemnts in any S3 compatible bucket, we use AWS +# Store attachments in any S3 compatible bucket, we use AWS APP_AWS_ACCESS_KEY_ID="" # AWS Access Key ID APP_AWS_SECRET_ACCESS_KEY="" # AWS Secret Access Key APP_AWS_REGION="" # AWS Region diff --git a/apps/app/instrumentation-client.ts b/apps/app/instrumentation-client.ts new file mode 100644 index 000000000..6ea5a015f --- /dev/null +++ b/apps/app/instrumentation-client.ts @@ -0,0 +1,9 @@ +import { initBotId } from 'botid/client/core'; + +initBotId({ + protect: [ + { path: '/api/chat', method: 'POST' }, + { path: '/api/tasks-automations/chat', method: 'POST' }, + { path: '/api/tasks-automations/errors', method: 'POST' }, + ], +}); diff --git a/apps/app/markdown.d.ts b/apps/app/markdown.d.ts new file mode 100644 index 000000000..43d00fea7 --- /dev/null +++ b/apps/app/markdown.d.ts @@ -0,0 +1,4 @@ +declare module '*.md' { + const content: string + export default content +} diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index 7201d19cd..254702356 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -1,17 +1,46 @@ +import { PrismaPlugin } from '@prisma/nextjs-monorepo-workaround-plugin'; +import { withBotId } from 'botid/next/config'; import type { NextConfig } from 'next'; import path from 'path'; + import './src/env.mjs'; const isStandalone = process.env.NEXT_OUTPUT_STANDALONE === 'true'; const config: NextConfig = { + // Ensure Turbopack can import .md files as raw strings during dev + turbopack: { + root: path.join(__dirname, '..', '..'), + rules: { + '*.md': { + loaders: ['raw-loader'], + as: '*.js', + }, + }, + }, + webpack: (config, { isServer }) => { + if (isServer) { + // Very important, DO NOT REMOVE, it's needed for Prisma to work in the server bundle + config.plugins = [...config.plugins, new PrismaPlugin()]; + } + + // Enable importing .md files as raw strings during webpack builds + config.module = config.module || { rules: [] }; + config.module.rules = config.module.rules || []; + config.module.rules.push({ + test: /\.md$/, + type: 'asset/source', + }); + + return config; + }, // Use S3 bucket for static assets with app-specific path assetPrefix: process.env.NODE_ENV === 'production' && process.env.STATIC_ASSETS_URL ? `${process.env.STATIC_ASSETS_URL}/app` : '', reactStrictMode: true, - transpilePackages: ['@trycompai/db'], + transpilePackages: ['@trycompai/db', '@prisma/client'], images: { remotePatterns: [ { @@ -67,4 +96,4 @@ const config: NextConfig = { }, }; -export default config; +export default withBotId(config); diff --git a/apps/app/package.json b/apps/app/package.json index 40535ae9d..e746ddbf4 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -8,6 +8,7 @@ "@ai-sdk/provider": "^2.0.0", "@ai-sdk/react": "^2.0.0", "@ai-sdk/rsc": "^1.0.0", + "@aws-sdk/client-lambda": "^3.891.0", "@aws-sdk/client-s3": "^3.859.0", "@aws-sdk/client-sts": "^3.808.0", "@aws-sdk/s3-request-presigner": "^3.859.0", @@ -25,6 +26,7 @@ "@dub/embed-react": "^0.0.16", "@hookform/resolvers": "^5.1.1", "@mendable/firecrawl-js": "^1.24.0", + "@monaco-editor/react": "^4.7.0", "@nangohq/frontend": "^0.53.2", "@next/third-parties": "^15.3.1", "@number-flow/react": "^0.5.9", @@ -46,23 +48,26 @@ "@tiptap/extension-table-row": "^3.4.4", "@trigger.dev/react-hooks": "4.0.0", "@trigger.dev/sdk": "4.0.0", - "@trycompai/db": "^1.3.4", + "@trycompai/db": "^1.3.7", "@trycompai/email": "workspace:*", "@types/canvas-confetti": "^1.9.0", + "@types/react-syntax-highlighter": "^15.5.13", "@types/three": "^0.180.0", "@uploadthing/react": "^7.3.0", "@upstash/ratelimit": "^2.0.5", + "@vercel/sandbox": "^0.0.21", "@vercel/sdk": "^1.7.1", "ai": "^5.0.0", "axios": "^1.9.0", "better-auth": "^1.2.8", + "botid": "^1.5.5", "canvas-confetti": "^1.9.3", "d3": "^7.9.0", "dub": "^0.66.1", "framer-motion": "^12.18.1", "geist": "^1.3.1", "jspdf": "^3.0.2", - "lucide-react": "^0.534.0", + "lucide-react": "^0.544.0", "motion": "^12.9.2", "next": "^15.4.6", "next-safe-action": "^8.0.3", @@ -79,9 +84,12 @@ "react-hotkeys-hook": "^5.1.0", "react-intersection-observer": "^9.16.0", "react-markdown": "10.1.0", + "react-spinners": "^0.17.0", + "react-syntax-highlighter": "^15.6.6", "react-textarea-autosize": "^8.5.9", "react-use-draggable-scroll": "^0.4.7", "react-wrap-balancer": "^1.1.1", + "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "resend": "^4.4.1", @@ -91,6 +99,7 @@ "ts-pattern": "^5.7.0", "use-debounce": "^10.0.4", "use-long-press": "^3.3.0", + "use-stick-to-bottom": "^1.1.1", "xml2js": "^0.6.2", "zaraz-ts": "^1.2.0", "zod": "^3.25.76", @@ -116,6 +125,7 @@ "jsdom": "^26.1.0", "postcss": "^8.5.4", "prisma": "^6.13.0", + "raw-loader": "^4.0.2", "tailwindcss": "^4.1.8", "typescript": "^5.8.3", "vite-tsconfig-paths": "^5.1.4", diff --git a/apps/app/public/automation-bg.svg b/apps/app/public/automation-bg.svg new file mode 100644 index 000000000..e698d8c0f --- /dev/null +++ b/apps/app/public/automation-bg.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/app/public/compailogo.jpg b/apps/app/public/compailogo.jpg new file mode 100644 index 000000000..810ae3291 Binary files /dev/null and b/apps/app/public/compailogo.jpg differ diff --git a/apps/app/src/ai/constants.ts b/apps/app/src/ai/constants.ts new file mode 100644 index 000000000..22616fc8a --- /dev/null +++ b/apps/app/src/ai/constants.ts @@ -0,0 +1,25 @@ +import { type GatewayModelId } from '@ai-sdk/gateway'; + +export enum Models { + AmazonNovaPro = 'amazon/nova-pro', + AnthropicClaude4Sonnet = 'anthropic/claude-4-sonnet', + GoogleGeminiFlash = 'google/gemini-2.5-flash', + MoonshotKimiK2 = 'moonshotai/kimi-k2', + OpenAIGPT5 = 'openai/gpt-5', + OpenAIGPT5Mini = 'openai/gpt-5-mini', + OpenAIGPT4oMini = 'openai/gpt-4o-mini', + XaiGrok3Fast = 'xai/grok-3-fast', +} + +export const DEFAULT_MODEL = Models.OpenAIGPT5Mini; + +export const SUPPORTED_MODELS: GatewayModelId[] = [ + Models.AmazonNovaPro, + Models.AnthropicClaude4Sonnet, + Models.GoogleGeminiFlash, + Models.MoonshotKimiK2, + Models.OpenAIGPT5, + Models.OpenAIGPT5Mini, + Models.OpenAIGPT4oMini, + Models.XaiGrok3Fast, +]; diff --git a/apps/app/src/ai/gateway.ts b/apps/app/src/ai/gateway.ts new file mode 100644 index 000000000..c73c09384 --- /dev/null +++ b/apps/app/src/ai/gateway.ts @@ -0,0 +1,47 @@ +import { createGatewayProvider } from '@ai-sdk/gateway'; +import type { OpenAIResponsesProviderOptions } from '@ai-sdk/openai'; +import type { LanguageModelV2 } from '@ai-sdk/provider'; +import type { JSONValue } from 'ai'; +import { Models } from './constants'; + +export async function getAvailableModels() { + const gateway = gatewayInstance(); + const response = await gateway.getAvailableModels(); + return response.models.map((model) => ({ id: model.id, name: model.name })); +} + +export interface ModelOptions { + model: LanguageModelV2; + providerOptions?: Record>; + headers?: Record; +} + +export function getModelOptions( + modelId: string, + options?: { reasoningEffort?: 'minimal' | 'low' | 'medium' }, +): ModelOptions { + const gateway = gatewayInstance(); + if (modelId === Models.OpenAIGPT5 || modelId === Models.OpenAIGPT5Mini) { + return { + model: gateway(modelId), + providerOptions: { + openai: { + include: ['reasoning.encrypted_content'], + reasoningEffort: options?.reasoningEffort ?? 'low', + reasoningSummary: 'auto', + serviceTier: 'priority', + } satisfies OpenAIResponsesProviderOptions, + }, + }; + } + + return { + model: gateway(modelId), + }; +} + +function gatewayInstance() { + return createGatewayProvider({ + baseURL: process.env.AI_GATEWAY_BASE_URL, + }); +} diff --git a/apps/app/src/ai/messages/data-parts.ts b/apps/app/src/ai/messages/data-parts.ts new file mode 100644 index 000000000..ef04e5023 --- /dev/null +++ b/apps/app/src/ai/messages/data-parts.ts @@ -0,0 +1,44 @@ +import z from 'zod/v3'; + +export const errorSchema = z.object({ + message: z.string(), +}); + +export const dataPartSchema = z.object({ + 'create-sandbox': z.object({ + sandboxId: z.string().optional(), + status: z.enum(['loading', 'done', 'error']), + error: errorSchema.optional(), + }), + 'generating-files': z.object({ + paths: z.array(z.string()), + status: z.enum(['generating', 'uploading', 'uploaded', 'done', 'error']), + error: errorSchema.optional(), + }), + 'run-command': z.object({ + sandboxId: z.string(), + commandId: z.string().optional(), + command: z.string(), + args: z.array(z.string()), + status: z.enum(['executing', 'running', 'waiting', 'done', 'error']), + exitCode: z.number().optional(), + error: errorSchema.optional(), + }), + 'get-sandbox-url': z.object({ + url: z.string().optional(), + status: z.enum(['loading', 'done']), + }), + 'report-errors': z.object({ + summary: z.string(), + paths: z.array(z.string()).optional(), + }), + 'store-to-s3': z.object({ + status: z.enum(['uploading', 'done', 'error']), + bucket: z.string().optional(), + key: z.string().optional(), + region: z.string().optional(), + error: errorSchema.optional(), + }), +}); + +export type DataPart = z.infer; diff --git a/apps/app/src/ai/messages/metadata.ts b/apps/app/src/ai/messages/metadata.ts new file mode 100644 index 000000000..13e3e9eb4 --- /dev/null +++ b/apps/app/src/ai/messages/metadata.ts @@ -0,0 +1,7 @@ +import z from 'zod/v3' + +export const metadataSchema = z.object({ + model: z.string(), +}) + +export type Metadata = z.infer diff --git a/apps/app/src/ai/secrets.ts b/apps/app/src/ai/secrets.ts new file mode 100644 index 000000000..3c5de21a5 --- /dev/null +++ b/apps/app/src/ai/secrets.ts @@ -0,0 +1,98 @@ +// Central registry for standard secrets used by automations and tools. +// AI-friendly design: a flat list with simple fields and helper lookups. + +export type SecretProvider = 'github'; + +export interface SecretEntry { + id: string; // stable identifier, e.g. 'github.token' + provider: SecretProvider; + name: string; // short name within provider, e.g. 'token' + envVar: string; // environment variable name, e.g. 'GITHUB_TOKEN' + description: string; + required: boolean; + docsUrl?: string; + aliases?: readonly string[]; // additional phrases an AI/user might use +} + +export const SECRETS: readonly SecretEntry[] = [ + { + id: 'github.token', + provider: 'github', + name: 'token', + envVar: 'GITHUB_TOKEN', + description: + 'GitHub token (PAT or App installation token) with read access to repository contents and metadata.', + required: true, + docsUrl: + 'https://docs.github.com/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token', + aliases: ['github token', 'gh token', 'github_pat', 'github personal access token'], + }, +] as const; + +// Lightweight indexes for fast lookup +const SECRET_BY_ID: Readonly> = Object.freeze( + Object.fromEntries(SECRETS.map((s) => [s.id, s])), +); + +const SECRET_BY_ENV: Readonly> = Object.freeze( + Object.fromEntries(SECRETS.map((s) => [s.envVar.toUpperCase(), s])), +); + +export function listSecrets(): readonly SecretEntry[] { + return SECRETS; +} + +export function listProviderSecrets(provider: SecretProvider): readonly SecretEntry[] { + return SECRETS.filter((s) => s.provider === provider); +} + +export function getSecretById(id: string): SecretEntry | undefined { + return SECRET_BY_ID[id]; +} + +export function getSecretByEnvVar(envVar: string): SecretEntry | undefined { + return SECRET_BY_ENV[envVar.toUpperCase()]; +} + +export function getEnvVarNameById(id: string): string | undefined { + return getSecretById(id)?.envVar; +} + +// Flexible resolver that accepts: id, env var, provider.name, or an alias phrase +export function resolveSecretIdentifier(identifier: string): SecretEntry | undefined { + const raw = identifier.trim(); + if (!raw) return undefined; + + // Exact id + const byId = getSecretById(raw); + if (byId) return byId; + + // Exact env var + const byEnv = getSecretByEnvVar(raw); + if (byEnv) return byEnv; + + const normalized = raw.toLowerCase().replace(/\s+/g, ' ').trim(); + + // provider.name form + const dotIdx = normalized.indexOf('.'); + if (dotIdx > 0) { + const provider = normalized.slice(0, dotIdx); + const name = normalized.slice(dotIdx + 1); + const match = SECRETS.find( + (s) => s.provider === (provider as SecretProvider) && s.name.toLowerCase() === name, + ); + if (match) return match; + } + + // Alias match + const byAlias = SECRETS.find((s) => + (s.aliases ?? []).some((a) => a.toLowerCase() === normalized), + ); + if (byAlias) return byAlias; + + // Provider-keyword fallback: e.g., 'github token' + const byTokens = SECRETS.find( + (s) => normalized.includes(s.provider) && normalized.includes(s.name.toLowerCase()), + ); + return byTokens; +} diff --git a/apps/app/src/ai/tools/create-sandbox.md b/apps/app/src/ai/tools/create-sandbox.md new file mode 100644 index 000000000..f8cf0075f --- /dev/null +++ b/apps/app/src/ai/tools/create-sandbox.md @@ -0,0 +1,55 @@ +Use this tool to create a new Vercel Sandbox — an ephemeral, isolated Linux container that serves as your development environment for the current session. This sandbox provides a secure workspace where you can upload files, install dependencies, run commands, start development servers, and preview web apps. Each sandbox is uniquely identified and must be referenced for all subsequent operations (e.g., file generation, command execution, or URL access). + +## When to Use This Tool + +Use this tool **once per session** when: + +1. You begin working on a new user request that requires code execution or file creation +2. No sandbox currently exists for the session +3. The user asks to start a new project, scaffold an application, or test code in a live environment +4. The user requests a fresh or reset environment + +## Sandbox Capabilities + +After creation, the sandbox allows you to: + +- Upload and manage files via `Generate Files` +- Execute shell commands with `Run Command` and `Wait Command` +- Access running servers through public URLs using `Get Sandbox URL` + +Each sandbox mimics a real-world development environment and supports rapid iteration and testing without polluting the local system. The base system is Amazon Linux 2023 with the following additional packages: + +``` +bind-utils bzip2 findutils git gzip iputils libicu libjpeg libpng ncurses-libs openssl openssl-libs pnpm procps tar unzip which whois zstd +``` + +You can install additional packages using the `dnf` package manager. You can NEVER use port 8080 as it is reserved for internal applications. When requested, you need to use a different port. + +## Best Practices + +- Create the sandbox at the beginning of the session or when the user initiates a coding task +- Track and reuse the sandbox ID throughout the session +- Do not create a second sandbox unless explicitly instructed +- If the user requests an environment reset, you may create a new sandbox **after confirming their intent** + +## Examples of When to Use This Tool + + +User: Can we start fresh? I want to rebuild the project from scratch. +Assistant: Got it — I’ll create a new sandbox so we can start clean. +*Calls Create Sandbox* + + +## When NOT to Use This Tool + +Skip using this tool when: + +1. A sandbox has already been created for the current session +2. You only need to upload files (use Generate Files) +3. You want to execute or wait for a command (use Run Command / Wait Command) +4. You want to preview the application (use Get Sandbox URL) +5. The user hasn’t asked to reset the environment + +## Summary + +Use Create Sandbox to initialize a secure, temporary development environment — but **only once per session**. Treat the sandbox as the core workspace for all follow-up actions unless the user explicitly asks to discard and start anew. diff --git a/apps/app/src/ai/tools/create-sandbox.ts b/apps/app/src/ai/tools/create-sandbox.ts new file mode 100644 index 000000000..64c1ee7a9 --- /dev/null +++ b/apps/app/src/ai/tools/create-sandbox.ts @@ -0,0 +1,75 @@ +import { Sandbox } from '@vercel/sandbox'; +import type { UIMessage, UIMessageStreamWriter } from 'ai'; +import { tool } from 'ai'; +import z from 'zod/v3'; +import type { DataPart } from '../messages/data-parts'; +import description from './create-sandbox.md'; +import { getRichError } from './get-rich-error'; + +interface Params { + writer: UIMessageStreamWriter>; +} + +export const createSandbox = ({ writer }: Params) => + tool({ + description, + inputSchema: z.object({ + timeout: z + .number() + .min(600000) + .max(2700000) + .optional() + .describe( + 'Maximum time in milliseconds the Vercel Sandbox will remain active before automatically shutting down. Minimum 600000ms (10 minutes), maximum 2700000ms (45 minutes). Defaults to 600000ms (10 minutes). The sandbox will terminate all running processes when this timeout is reached.', + ), + ports: z + .array(z.number()) + .max(2) + .optional() + .describe( + 'Array of network ports to expose and make accessible from outside the Vercel Sandbox. These ports allow web servers, APIs, or other services running inside the Vercel Sandbox to be reached externally. Common ports include 3000 (Next.js), 8000 (Python servers), 5000 (Flask), etc.', + ), + }), + execute: async ({ timeout, ports }, { toolCallId }) => { + writer.write({ + id: toolCallId, + type: 'data-create-sandbox', + data: { status: 'loading' }, + }); + + try { + const sandbox = await Sandbox.create({ + timeout: timeout ?? 600000, + ports, + }); + + writer.write({ + id: toolCallId, + type: 'data-create-sandbox', + data: { sandboxId: sandbox.sandboxId, status: 'done' }, + }); + + return ( + `Sandbox created with ID: ${sandbox.sandboxId}.` + + `\nYou can now upload files, run commands, and access services on the exposed ports.` + ); + } catch (error) { + const richError = getRichError({ + action: 'Creating Sandbox', + error, + }); + + writer.write({ + id: toolCallId, + type: 'data-create-sandbox', + data: { + error: { message: richError.error.message }, + status: 'error', + }, + }); + + console.log('Error creating Sandbox:', richError.error); + return richError.message; + } + }, + }); diff --git a/apps/app/src/ai/tools/exa-search.ts b/apps/app/src/ai/tools/exa-search.ts new file mode 100644 index 000000000..947a6d21e --- /dev/null +++ b/apps/app/src/ai/tools/exa-search.ts @@ -0,0 +1,144 @@ +import { openai } from '@ai-sdk/openai'; +import { generateObject, tool } from 'ai'; +import { z } from 'zod'; + +const exaSearchSchema = z.object({ + query: z.string().min(1).describe('The search query to find relevant web content'), + numResults: z.number().min(1).max(10).default(5).describe('Number of search results to return'), + category: z + .enum([ + 'general', + 'company', + 'research_paper', + 'news', + 'github', + 'tweet', + 'movie', + 'song', + 'personal_site', + 'pdf', + ]) + .default('general') + .describe('Category to search within'), + startPublishedDate: z + .string() + .optional() + .describe('Start date for filtering results (ISO format: YYYY-MM-DD)'), + endPublishedDate: z + .string() + .optional() + .describe('End date for filtering results (ISO format: YYYY-MM-DD)'), + useAutoprompt: z + .boolean() + .default(true) + .describe('Whether to enhance the query with additional context for better results'), + type: z + .enum(['keyword', 'neural']) + .default('neural') + .describe('Search type - neural for semantic search, keyword for exact matches'), +}); + +export const exaSearchTool = () => + tool({ + description: + 'Search the web using Exa AI for relevant, high-quality content. Exa uses neural search to find semantically similar content beyond just keyword matching. Use this to find documentation, articles, research papers, and other web content.', + inputSchema: exaSearchSchema, + execute: async (args: unknown) => { + const parsedArgs = exaSearchSchema.parse(args); + + const EXA_API_KEY = process.env.EXA_API_KEY; + if (!EXA_API_KEY) { + throw new Error('EXA_API_KEY environment variable is not set'); + } + + try { + const searchParams: any = { + query: parsedArgs.query, + num_results: parsedArgs.numResults, + category: parsedArgs.category, + use_autoprompt: parsedArgs.useAutoprompt, + type: parsedArgs.type, + }; + + // Add optional date filters + if (parsedArgs.startPublishedDate) { + searchParams.start_published_date = parsedArgs.startPublishedDate; + } + if (parsedArgs.endPublishedDate) { + searchParams.end_published_date = parsedArgs.endPublishedDate; + } + + const response = await fetch('https://api.exa.ai/search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': EXA_API_KEY, + }, + body: JSON.stringify(searchParams), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Exa search failed: ${response.status} - ${error}`); + } + + const data = await response.json(); + + // Format the results for the AI to use + const formattedResults = data.results.map((result: any) => ({ + title: result.title, + url: result.url, + snippet: result.text || result.snippet || '', + publishedDate: result.published_date, + author: result.author, + score: result.score, + })); + + // Generate a structured summary of what was found + let summary = ''; + try { + const summaryResponse = await generateObject({ + model: openai('gpt-4o-mini'), + schema: z.object({ + action: z.string().describe('What action was performed, e.g. "Searched for"'), + target: z.string().describe('What was searched for or targeted'), + result: z.string().describe('Brief description of what was found'), + }), + messages: [ + { + role: 'system', + content: + 'You are a helpful assistant that summarizes search results in a structured way for non-technical users.', + }, + { + role: 'user', + content: `Summarize this search:\n\nQuery: "${parsedArgs.query}"\nFound ${data.results.length} results.\n\nTop results:\n${formattedResults + .slice(0, 3) + .map((r: any) => `- ${r.title}`) + .join('\n')}`, + }, + ], + maxRetries: 1, + }); + + // Format as a readable string + summary = `${summaryResponse.object.action} "${summaryResponse.object.target}" - ${summaryResponse.object.result}`; + } catch (err) { + // If summary generation fails, just continue without it + console.error('Failed to generate summary:', err); + } + + return { + query: parsedArgs.query, + totalResults: data.results.length, + results: formattedResults, + summary, + }; + } catch (error) { + console.error('Exa search error:', error); + throw new Error( + `Failed to search with Exa: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + }, + }); diff --git a/apps/app/src/ai/tools/firecrawl.ts b/apps/app/src/ai/tools/firecrawl.ts new file mode 100644 index 000000000..bad53ed2f --- /dev/null +++ b/apps/app/src/ai/tools/firecrawl.ts @@ -0,0 +1,169 @@ +import { openai } from '@ai-sdk/openai'; +import { generateObject, tool } from 'ai'; +import { z } from 'zod'; + +const firecrawlSchema = z.object({ + url: z.string().url().describe('The URL of the website to crawl and extract content from'), + formats: z + .array(z.enum(['markdown', 'html', 'rawHtml', 'links', 'screenshot'])) + .default(['markdown']) + .describe('Formats to return - markdown is usually best for AI processing'), + onlyMainContent: z + .boolean() + .default(true) + .describe('Whether to extract only the main content, removing navigation, footers, etc.'), + includeTags: z + .array(z.string()) + .optional() + .describe('HTML tags to include in the extraction (e.g., ["article", "main", "div.content"])'), + excludeTags: z + .array(z.string()) + .optional() + .describe('HTML tags to exclude from extraction (e.g., ["nav", "footer", "aside"])'), + waitFor: z + .number() + .min(0) + .max(10000) + .optional() + .describe('Time to wait in ms for JavaScript to load (for dynamic sites)'), + timeout: z + .number() + .min(1000) + .max(30000) + .default(15000) + .describe('Maximum time in ms to wait for the page to load'), +}); + +export const firecrawlTool = () => + tool({ + description: + 'Crawl and extract content from any website using Firecrawl v2 API. This tool can extract clean markdown, HTML, or raw content from web pages, handle JavaScript-rendered sites, and take screenshots. Use this after finding relevant URLs with Exa search to get the full content. Supports modern web apps with dynamic content.', + inputSchema: firecrawlSchema, + execute: async (args: unknown) => { + const parsedArgs = firecrawlSchema.parse(args); + + const FIRECRAWL_API_KEY = process.env.FIRECRAWL_API_KEY; + if (!FIRECRAWL_API_KEY) { + throw new Error('FIRECRAWL_API_KEY environment variable is not set'); + } + + try { + const scrapeParams: any = { + url: parsedArgs.url, + formats: parsedArgs.formats, + onlyMainContent: parsedArgs.onlyMainContent, + timeout: parsedArgs.timeout, + }; + + // Add optional parameters + if (parsedArgs.includeTags && parsedArgs.includeTags.length > 0) { + scrapeParams.includeTags = parsedArgs.includeTags; + } + if (parsedArgs.excludeTags && parsedArgs.excludeTags.length > 0) { + scrapeParams.excludeTags = parsedArgs.excludeTags; + } + if (parsedArgs.waitFor !== undefined) { + scrapeParams.waitFor = parsedArgs.waitFor; + } + + const response = await fetch('https://api.firecrawl.dev/v2/scrape', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${FIRECRAWL_API_KEY}`, + }, + body: JSON.stringify(scrapeParams), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Firecrawl scrape failed: ${response.status} - ${error}`); + } + + const data = await response.json(); + + if (!data.success) { + throw new Error(`Firecrawl scrape failed: ${data.error || 'Unknown error'}`); + } + + // Return the scraped content in a structured format + const result: any = { + url: parsedArgs.url, + title: data.data?.metadata?.title || '', + description: data.data?.metadata?.description || '', + }; + + // Add the requested formats (v2 API structure) + if (parsedArgs.formats.includes('markdown') && data.data?.markdown) { + result.markdown = data.data.markdown; + } + if (parsedArgs.formats.includes('html') && data.data?.html) { + result.html = data.data.html; + } + if (parsedArgs.formats.includes('rawHtml') && data.data?.rawHtml) { + result.rawHtml = data.data.rawHtml; + } + if (parsedArgs.formats.includes('links') && data.data?.links) { + result.links = data.data.links; + } + if (parsedArgs.formats.includes('screenshot') && data.data?.screenshot) { + result.screenshot = data.data.screenshot; + } + + // Add metadata + result.metadata = { + sourceURL: data.data?.metadata?.sourceURL || parsedArgs.url, + pageStatusCode: data.data?.metadata?.pageStatusCode, + pageError: data.data?.metadata?.pageError, + ogTitle: data.data?.metadata?.ogTitle, + ogDescription: data.data?.metadata?.ogDescription, + ogImage: data.data?.metadata?.ogImage, + }; + + // Generate a structured summary of what was extracted + let summary = ''; + try { + const pageInfo = []; + if (result.title) pageInfo.push(`Title: "${result.title}"`); + if (result.description) pageInfo.push(`Description: "${result.description}"`); + if (result.markdown) + pageInfo.push(`Extracted ${result.markdown.length} characters of content`); + + const summaryResponse = await generateObject({ + model: openai('gpt-4o-mini'), + schema: z.object({ + action: z.string().describe('What action was performed, e.g. "Visited"'), + target: z.string().describe('What was visited or targeted'), + result: z.string().describe('Brief description of what was extracted'), + }), + messages: [ + { + role: 'system', + content: + 'You are a helpful assistant that summarizes web scraping results in a structured way for non-technical users.', + }, + { + role: 'user', + content: `Summarize what was extracted from this webpage:\n\nURL: ${parsedArgs.url}\n${pageInfo.join('\n')}`, + }, + ], + maxRetries: 1, + }); + + // Format as a readable string + summary = `${summaryResponse.object.action} ${summaryResponse.object.target} - ${summaryResponse.object.result}`; + } catch (err) { + // If summary generation fails, just continue without it + console.error('Failed to generate summary:', err); + } + + result.summary = summary; + return result; + } catch (error) { + console.error('Firecrawl error:', error); + throw new Error( + `Failed to crawl with Firecrawl: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + }, + }); diff --git a/apps/app/src/ai/tools/generate-files.md b/apps/app/src/ai/tools/generate-files.md new file mode 100644 index 000000000..6734757ad --- /dev/null +++ b/apps/app/src/ai/tools/generate-files.md @@ -0,0 +1,74 @@ +Use this tool to generate and upload code files into an existing Vercel Sandbox. It leverages an LLM to create file contents based on the current conversation context and user intent, then writes them directly into the sandbox file system. + +The generated files should be considered correct on first iteration and suitable for immediate use in the sandbox environment. This tool is essential for scaffolding applications, adding new features, writing configuration files, or fixing missing components. + +All file paths must be relative to the sandbox root (e.g., `src/index.ts`, `package.json`, `components/Button.tsx`). + +## When to Use This Tool + +Use Generate Files when: + +1. You need to create one or more new files as part of a feature, scaffold, or fix +2. The user requests code that implies file creation (e.g., new routes, APIs, components, services) +3. You need to bootstrap a new application structure inside a sandbox +4. You’re completing a multi-step task that involves generating or updating source code +5. A prior command failed due to a missing file, and you need to supply it + +## File Generation Guidelines + +- Every file must be complete, valid, and runnable where applicable +- File contents must reflect the user’s intent and the overall session context +- File paths must be well-structured and use consistent naming conventions +- Generated files should assume compatibility with other existing files in the sandbox + +### Lambda (.js) files in `lambdas/` MUST follow this structure + +- Line 1 must be: `module.exports = async (event) => {` +- No code/comments/imports before that line +- No code after the closing `};` +- All requires and helper functions must be inside the exported function +- No `process.env`; use `getSecret` inside the function + +## Best Practices + +- Avoid redundant file generation if the file already exists and is unchanged +- If a file was already created earlier in the session at the same path, do NOT regenerate it unless the user asked to update it. Instead, confirm the existing path and proceed to next steps. +- Do NOT create a new file path to work around the above rule. If content is wrong, fix the existing file at the same path, or ask the user to confirm overwriting. +- If you detect incorrect content (e.g., `process.env` usage, export shape), you SHOULD automatically update the existing file to comply with the authoring rules. Do not ask for permission for these safe, corrective edits. +- Use conventional file/folder structures for the tech stack in use +- If replacing an existing file, ensure the update fully satisfies the user’s request + +## Examples of When to Use This Tool + + +User: Add a `NavBar.tsx` component and include it in `App.tsx` +Assistant: I’ll generate the `NavBar.tsx` file and update `App.tsx` to include it. +*Uses Generate Files to create:* +- `components/NavBar.tsx` +- Modified `App.tsx` with import and usage of `NavBar` + + + +User: Let’s scaffold a simple Express server with a `/ping` route. +Assistant: I’ll generate the necessary files to start the Express app. +*Uses Generate Files to create:* +- `package.json` with Express as a dependency +- `index.js` with basic server and `/ping` route + + +## When NOT to Use This Tool + +Avoid using this tool when: + +1. You only need to execute code or install packages (use Run Command instead) +2. You’re waiting for a command to finish (use Wait Command) +3. You want to preview a running server or UI (use Get Sandbox URL) +4. You haven’t created a sandbox yet (use Create Sandbox first) + +## Output Behavior + +After generation, the tool will return a list of the files created, including their paths and contents. These can then be inspected, referenced, or used in subsequent commands. + +## Summary + +Use Generate Files to programmatically create or update files in your Vercel Sandbox. It enables fast iteration, contextual coding, and dynamic file management — all driven by user intent and conversation context. diff --git a/apps/app/src/ai/tools/generate-files.ts b/apps/app/src/ai/tools/generate-files.ts new file mode 100644 index 000000000..a41642ab9 --- /dev/null +++ b/apps/app/src/ai/tools/generate-files.ts @@ -0,0 +1,105 @@ +import { Sandbox } from '@vercel/sandbox'; +import type { UIMessage, UIMessageStreamWriter } from 'ai'; +import { tool } from 'ai'; +import z from 'zod/v3'; +import type { DataPart } from '../messages/data-parts'; +import description from './generate-files.md'; +import { getContents, type File } from './generate-files/get-contents'; +import { getWriteFiles } from './generate-files/get-write-files'; +import { getRichError } from './get-rich-error'; + +interface Params { + modelId: string; + writer: UIMessageStreamWriter>; +} + +export const generateFiles = ({ writer, modelId }: Params) => + tool({ + description, + inputSchema: z.object({ + sandboxId: z.string(), + paths: z.array(z.string()), + }), + execute: async ({ sandboxId, paths }, { toolCallId, messages }) => { + writer.write({ + id: toolCallId, + type: 'data-generating-files', + data: { paths: [], status: 'generating' }, + }); + + let sandbox: Sandbox | null = null; + + try { + sandbox = await Sandbox.get({ sandboxId }); + } catch (error) { + const richError = getRichError({ + action: 'get sandbox by id', + args: { sandboxId }, + error, + }); + + writer.write({ + id: toolCallId, + type: 'data-generating-files', + data: { error: richError.error, paths: [], status: 'error' }, + }); + + return richError.message; + } + + const writeFiles = getWriteFiles({ sandbox, toolCallId, writer }); + const iterator = getContents({ messages, modelId, paths }); + const uploaded: File[] = []; + + try { + for await (const chunk of iterator) { + if (chunk.files.length > 0) { + const error = await writeFiles(chunk); + if (error) { + return error; + } else { + uploaded.push(...chunk.files); + } + } else { + writer.write({ + id: toolCallId, + type: 'data-generating-files', + data: { + status: 'generating', + paths: chunk.paths, + }, + }); + } + } + } catch (error) { + const richError = getRichError({ + action: 'generate file contents', + args: { modelId, paths }, + error, + }); + + writer.write({ + id: toolCallId, + type: 'data-generating-files', + data: { + error: richError.error, + status: 'error', + paths, + }, + }); + + return richError.message; + } + + writer.write({ + id: toolCallId, + type: 'data-generating-files', + data: { paths: uploaded.map((file) => file.path), status: 'done' }, + }); + + return `Successfully generated and uploaded ${ + uploaded.length + } files. Their paths and contents are as follows: + ${uploaded.map((file) => `Path: ${file.path}\nContent: ${file.content}\n`).join('\n')}`; + }, + }); diff --git a/apps/app/src/ai/tools/generate-files/get-contents.ts b/apps/app/src/ai/tools/generate-files/get-contents.ts new file mode 100644 index 000000000..d1aa6cec7 --- /dev/null +++ b/apps/app/src/ai/tools/generate-files/get-contents.ts @@ -0,0 +1,93 @@ +import { getModelOptions } from '@/ai/gateway'; +import { streamObject, type ModelMessage } from 'ai'; +import z from 'zod/v3'; +import { Deferred } from '../../../app/(app)/[orgId]/tasks/[taskId]/automation/lib/deferred'; + +export type File = z.infer; + +const fileSchema = z.object({ + path: z + .string() + .describe( + "Path to the file in the Vercel Sandbox (relative paths from sandbox root, e.g., 'src/main.js', 'package.json', 'components/Button.tsx')", + ), + content: z + .string() + .describe( + 'The content of the file as a utf8 string (complete file contents that will replace any existing file at this path)', + ), +}); + +interface Params { + messages: ModelMessage[]; + modelId: string; + paths: string[]; +} + +interface FileContentChunk { + files: z.infer[]; + paths: string[]; + written: string[]; +} + +export async function* getContents(params: Params): AsyncGenerator { + const generated: z.infer[] = []; + const deferred = new Deferred(); + const result = streamObject({ + ...getModelOptions(params.modelId, { reasoningEffort: 'minimal' }), + system: + "You are a file content generator. You must generate files based on the conversation history and the provided paths. NEVER generate lock files (pnpm-lock.yaml, package-lock.json, yarn.lock) - these are automatically created by package managers.\n\nSTRICT LAMBDA RULES:\nFor any file whose path matches /^lambdas\\\/.*\\.js$/, you MUST adhere to ALL of the following or REGENERATE until compliant:\n1) The first non-whitespace characters in the file MUST be: module.exports = async (event) => {\n2) There MUST be NO code, comments, imports, or variables outside the exported function (neither before nor after)\n3) All require(...) and helper functions MUST be inside the exported function\n4) Do NOT use process.env for secrets. Use getSecret inside the function.\n5) Output raw file contents only (no markdown fences).\n6) Networking: ONLY use global fetch. NEVER use https/http/node:https/node:http/axios/node-fetch.\n\nDISALLOWED EXAMPLE (never produce):\n// comment\nconst https = require('https');\nmodule.exports = async (event) => { /* ... */ };\n\nALLOWED SHAPE (example):\nmodule.exports = async (event) => {\n const u = new URL('https://api.example.com/resource');\n const res = await fetch(u);\n const data = await res.json();\n return { ok: true, data };\n};", + messages: [ + ...params.messages, + { + role: 'user', + content: `Generate the content of the following files according to the conversation: ${params.paths.map( + (path) => `\n - ${path}`, + )}`, + }, + ], + schema: z.object({ files: z.array(fileSchema) }), + onError: (error) => { + deferred.reject(error); + console.error('Error communicating with AI'); + console.error(JSON.stringify(error, null, 2)); + }, + }); + + for await (const items of result.partialObjectStream) { + if (!Array.isArray(items?.files)) { + continue; + } + + const written = generated.map((file) => file.path); + const paths = written.concat( + items.files + .slice(generated.length, items.files.length - 1) + .flatMap((f) => (f?.path ? [f.path] : [])), + ); + + const files = items.files + .slice(generated.length, items.files.length - 2) + .map((file) => fileSchema.parse(file)); + + if (files.length > 0) { + yield { files, paths, written }; + generated.push(...files); + } else { + yield { files: [], written, paths }; + } + } + + const raceResult = await Promise.race([result.object, deferred.promise]); + if (!raceResult) { + throw new Error('Unexpected Error: Deferred was resolved before the result'); + } + + const written = generated.map((file) => file.path); + const files = raceResult.files.slice(generated.length); + const paths = written.concat(files.map((file) => file.path)); + if (files.length > 0) { + yield { files, written, paths }; + generated.push(...files); + } +} diff --git a/apps/app/src/ai/tools/generate-files/get-write-files.ts b/apps/app/src/ai/tools/generate-files/get-write-files.ts new file mode 100644 index 000000000..ac447c945 --- /dev/null +++ b/apps/app/src/ai/tools/generate-files/get-write-files.ts @@ -0,0 +1,59 @@ +import type { DataPart } from '../../messages/data-parts' +import type { File } from './get-contents' +import type { Sandbox } from '@vercel/sandbox' +import type { UIMessageStreamWriter, UIMessage } from 'ai' +import { getRichError } from '../get-rich-error' + +interface Params { + sandbox: Sandbox + toolCallId: string + writer: UIMessageStreamWriter> +} + +export function getWriteFiles({ sandbox, toolCallId, writer }: Params) { + return async function writeFiles(params: { + written: string[] + files: File[] + paths: string[] + }) { + const paths = params.written.concat(params.files.map((file) => file.path)) + writer.write({ + id: toolCallId, + type: 'data-generating-files', + data: { paths, status: 'uploading' }, + }) + + try { + await sandbox.writeFiles( + params.files.map((file) => ({ + content: Buffer.from(file.content, 'utf8'), + path: file.path, + })) + ) + } catch (error) { + const richError = getRichError({ + action: 'write files to sandbox', + args: params, + error, + }) + + writer.write({ + id: toolCallId, + type: 'data-generating-files', + data: { + error: richError.error, + status: 'error', + paths: params.paths, + }, + }) + + return richError.message + } + + writer.write({ + id: toolCallId, + type: 'data-generating-files', + data: { paths, status: 'uploaded' }, + }) + } +} diff --git a/apps/app/src/ai/tools/get-rich-error.ts b/apps/app/src/ai/tools/get-rich-error.ts new file mode 100644 index 000000000..354313143 --- /dev/null +++ b/apps/app/src/ai/tools/get-rich-error.ts @@ -0,0 +1,43 @@ +import { APIError } from '@vercel/sandbox/dist/api-client/api-error' + +interface Params { + args?: Record + action: string + error: unknown +} + +/** + * Allows to parse a thrown error to check its metadata and construct a rich + * message that can be handed to the LLM. + */ +export function getRichError({ action, args, error }: Params) { + const fields = getErrorFields(error) + let message = `Error during ${action}: ${fields.message}` + if (args) message += `\nParameters: ${JSON.stringify(args, null, 2)}` + if (fields.json) message += `\nJSON: ${JSON.stringify(fields.json, null, 2)}` + if (fields.text) message += `\nText: ${fields.text}` + return { + message: message, + error: fields, + } +} + +function getErrorFields(error: unknown) { + if (!(error instanceof Error)) { + return { + message: String(error), + json: error, + } + } else if (error instanceof APIError) { + return { + message: error.message, + json: error.json, + text: error.text, + } + } else { + return { + message: error.message, + json: error, + } + } +} diff --git a/apps/app/src/ai/tools/get-sandbox-url.md b/apps/app/src/ai/tools/get-sandbox-url.md new file mode 100644 index 000000000..507685567 --- /dev/null +++ b/apps/app/src/ai/tools/get-sandbox-url.md @@ -0,0 +1,52 @@ +Use this tool to retrieve a publicly accessible URL for a specific port that was exposed during the creation of a Vercel Sandbox. This allows users (and the assistant) to preview web applications, access APIs, or interact with services running inside the sandbox via HTTP. + +⚠️ The requested port must have been explicitly declared when the sandbox was created. If the port was not exposed at sandbox creation time, this tool will NOT work for that port. + +## When to Use This Tool + +Use Get Sandbox URL when: + +1. A service or web server is running on a port that was exposed during sandbox creation +2. You need to share a live preview link with the user +3. You want to access a running server inside the sandbox via HTTP +4. You need to programmatically test or call an internal endpoint running in the sandbox + +## Critical Requirements + +- The port must have been **explicitly exposed** in the `Create Sandbox` step + - Example: `ports: [3000]` +- The command serving on that port must be actively running + - Use `Run Command` followed by `Wait Command` (if needed) to start the server + +## Best Practices + +- Only call this tool after the server process has successfully started +- Use typical ports based on framework defaults (e.g., 3000 for Next.js, 5173 for Vite, 8080 for Node APIs) +- If multiple services run on different ports, ensure each port was exposed up front during sandbox creation +- Don’t attempt to expose or discover ports dynamically after creation — only predefined ports are valid + +## When NOT to Use This Tool + +Avoid using this tool when: + +1. The port was **not declared** during sandbox creation — it will not be accessible +2. No server is running on the specified port +3. You haven't started the service yet or haven't waited for it to boot up +4. You are referencing a transient script or CLI command (not a persistent server) + +## Example + + +User: Can I preview the app after it's built? +Assistant: +1. Create Sandbox: expose port 3000 +2. Generate Files: scaffold the app +3. Run Command: `npm run dev` +4. (Optional) Wait Command +5. Get Sandbox URL: port 3000 +→ Returns: a public URL the user can open in a browser + + +## Summary + +Use Get Sandbox URL to access live previews of services running inside the sandbox — but only for ports that were explicitly exposed during sandbox creation. If the port wasn’t declared, it will not be accessible externally. diff --git a/apps/app/src/ai/tools/get-sandbox-url.ts b/apps/app/src/ai/tools/get-sandbox-url.ts new file mode 100644 index 000000000..a809dfe11 --- /dev/null +++ b/apps/app/src/ai/tools/get-sandbox-url.ts @@ -0,0 +1,45 @@ +import { Sandbox } from '@vercel/sandbox'; +import type { UIMessage, UIMessageStreamWriter } from 'ai'; +import { tool } from 'ai'; +import z from 'zod/v3'; +import type { DataPart } from '../messages/data-parts'; +import description from './get-sandbox-url.md'; + +interface Params { + writer: UIMessageStreamWriter>; +} + +export const getSandboxURL = ({ writer }: Params) => + tool({ + description, + inputSchema: z.object({ + sandboxId: z + .string() + .describe( + "The unique identifier of the Vercel Sandbox (e.g., 'sbx_abc123xyz'). This ID is returned when creating a Vercel Sandbox and is used to reference the specific sandbox instance.", + ), + port: z + .number() + .describe( + 'The port number where a service is running inside the Vercel Sandbox (e.g., 3000 for Next.js dev server, 8000 for Python apps, 5000 for Flask). The port must have been exposed when the sandbox was created or when running commands.', + ), + }), + execute: async ({ sandboxId, port }, { toolCallId }) => { + writer.write({ + id: toolCallId, + type: 'data-get-sandbox-url', + data: { status: 'loading' }, + }); + + const sandbox = await Sandbox.get({ sandboxId }); + const url = sandbox.domain(port); + + writer.write({ + id: toolCallId, + type: 'data-get-sandbox-url', + data: { url, status: 'done' }, + }); + + return { url }; + }, + }); diff --git a/apps/app/src/ai/tools/index.ts b/apps/app/src/ai/tools/index.ts new file mode 100644 index 000000000..cf1f23bb3 --- /dev/null +++ b/apps/app/src/ai/tools/index.ts @@ -0,0 +1,24 @@ +import type { InferUITools, UIMessage, UIMessageStreamWriter } from 'ai'; +import type { DataPart } from '../messages/data-parts'; +import { createSandbox } from './create-sandbox'; +import { generateFiles } from './generate-files'; +import { getSandboxURL } from './get-sandbox-url'; +import { runCommand } from './run-command'; +import { storeToS3 } from './store-to-s3'; + +interface Params { + modelId: string; + writer: UIMessageStreamWriter>; +} + +export function tools({ modelId, writer }: Params) { + return { + createSandbox: createSandbox({ writer }), + generateFiles: generateFiles({ writer, modelId }), + getSandboxURL: getSandboxURL({ writer }), + runCommand: runCommand({ writer }), + storeToS3: storeToS3({ writer }), + }; +} + +export type ToolSet = InferUITools>; diff --git a/apps/app/src/ai/tools/prompt-for-info.ts b/apps/app/src/ai/tools/prompt-for-info.ts new file mode 100644 index 000000000..48b521185 --- /dev/null +++ b/apps/app/src/ai/tools/prompt-for-info.ts @@ -0,0 +1,45 @@ +import { tool } from 'ai'; +import { z } from 'zod'; + +export function promptForInfoTool() { + return tool({ + name: 'promptForInfo', + description: 'Prompt the user to provide missing information needed for the automation', + inputSchema: z.object({ + fields: z + .array( + z.object({ + name: z.string().describe('The field name (e.g., "github_org", "repo_name")'), + label: z.string().describe('Human-readable label for the field'), + description: z + .string() + .optional() + .describe('Help text explaining what this field is for'), + placeholder: z.string().optional().describe('Placeholder text for the input'), + defaultValue: z.string().optional().describe('Default value if any'), + required: z.boolean().default(true).describe('Whether this field is required'), + }), + ) + .describe('List of fields to prompt the user for'), + reason: z.string().describe('Explanation of why this information is needed'), + }), + execute: async ({ fields, reason }) => { + // Format the response in a way the frontend can parse + const formattedFields = fields + .map( + (field) => + `Field: ${field.name}|${field.label}|${field.description || ''}|${field.placeholder || ''}|${field.defaultValue || ''}|${field.required}`, + ) + .join('\n'); + + return `[INFO_REQUIRED] +Reason: ${reason} +${formattedFields} +[/INFO_REQUIRED] + +I need some additional information to create this automation. ${reason} + +Please provide the required information above, then let me know when you've added it so I can continue.`; + }, + }); +} diff --git a/apps/app/src/ai/tools/prompt-for-secret.ts b/apps/app/src/ai/tools/prompt-for-secret.ts new file mode 100644 index 000000000..740dffb7a --- /dev/null +++ b/apps/app/src/ai/tools/prompt-for-secret.ts @@ -0,0 +1,39 @@ +import { tool } from 'ai'; +import { z } from 'zod'; + +const promptForSecretSchema = z.object({ + secretName: z + .string() + .min(1) + .max(100) + .regex(/^[A-Z0-9_]+$/, 'Name must be uppercase letters, numbers, and underscores only'), + description: z.string().optional(), + category: z.string().optional(), + exampleValue: z.string().optional(), + reason: z.string().describe('Explain why this secret is needed for the automation'), +}); + +export const promptForSecretTool = () => + tool({ + description: + 'Prompt the user to add a secret that is required for the automation but not currently configured', + inputSchema: promptForSecretSchema, + execute: async (args: unknown) => { + const { secretName, description, category, exampleValue, reason } = + promptForSecretSchema.parse(args); + + // Return a special response that the frontend will recognize + // The message will be shown to the user and parsed by the chat component + return `[SECRET_REQUIRED] +Secret Name: ${secretName} +Description: ${description || 'No description provided'} +Category: ${category || 'automation'} +Example: ${exampleValue || 'No example provided'} +Reason: ${reason} +[/SECRET_REQUIRED] + +I need the secret "${secretName}" to create this automation. ${reason} + +Please click the button above to add this secret, then let me know when you've added it so I can continue.`; + }, + }); diff --git a/apps/app/src/ai/tools/run-command.md b/apps/app/src/ai/tools/run-command.md new file mode 100644 index 000000000..2dafae2ed --- /dev/null +++ b/apps/app/src/ai/tools/run-command.md @@ -0,0 +1,67 @@ +Use this tool to run a command inside an existing Vercel Sandbox. You can choose whether the command should block until completion or run in the background by setting the `wait` parameter: + +- `wait: true` → Command runs and **must complete** before the response is returned. +- `wait: false` → Command starts in the background, and the response returns immediately with its `commandId`. + +⚠️ Commands are stateless — each one runs in a fresh shell session with **no memory** of previous commands. You CANNOT rely on `cd`, but other state like shell exports or background processes from prior commands should be available. + +## When to Use This Tool + +Use Run Command when: + +1. You need to install dependencies (e.g., `pnpm install`) +2. You want to run a build or test process (e.g., `pnpm build`, `vite build`) +3. You need to launch a development server or long-running process +4. You need to compile or execute code within the sandbox +5. You want to run a task in the background without blocking the session + +## Sequencing Rules + +- If two commands depend on each other, **set `wait: true` on the first** to ensure it finishes before starting the second + - ✅ Good: Run `pnpm install` with `wait: true` → then run `pnpm dev` + - ❌ Bad: Run both with `wait: false` and expect them to be sequential +- Do **not** issue multiple sequential commands in one call + - ❌ `cd src && node index.js` + - ✅ `node src/index.js` +- Do **not** assume directory state is preserved — use full relative paths + +## Command Format + +- Separate the base command from its arguments + - ✅ `{ command: "pnpm", args: ["install", "--verbose"], wait: true }` + - ❌ `{ command: "pnpm install --verbose" }` +- Avoid shell syntax like pipes, redirections, or `&&`. If unavoidable, ensure it works in a stateless, single-session execution + +## When to Set `wait` to True + +- The next step depends on the result of the command +- The command must finish before accessing its output +- Example: Installing dependencies before building, compiling before running tests + +## When to Set `wait` to False + +- The command is intended to stay running indefinitely (e.g., a dev server) +- The command has no impact on subsequent operations (e.g., printing logs) + +## Other Rules + +- When running `pnpm dev` in a Next.js or Vite project, HMR can handle updates so generally you don't need to kill the server process and start it again after changing files. + +## Examples + + +User: Install dependencies and then run the dev server +Assistant: +1. Run Command: `{ command: "pnpm", args: ["install"], wait: true }` +2. Run Command: `{ command: "pnpm", args: ["run", "dev"], wait: false }` + + + +User: Build the app with Vite +Assistant: +Run Command: `{ command: "vite", args: ["build"], wait: true }` + + +## Summary + +Use Run Command to start shell commands in the sandbox, controlling execution flow with the `wait` flag. Commands are stateless and isolated — use relative paths, and only run long-lived processes with `wait: false`. diff --git a/apps/app/src/ai/tools/run-command.ts b/apps/app/src/ai/tools/run-command.ts new file mode 100644 index 000000000..7f12cc1ca --- /dev/null +++ b/apps/app/src/ai/tools/run-command.ts @@ -0,0 +1,193 @@ +import { Command, Sandbox } from '@vercel/sandbox'; +import type { UIMessage, UIMessageStreamWriter } from 'ai'; +import { tool } from 'ai'; +import z from 'zod/v3'; +import type { DataPart } from '../messages/data-parts'; +import { getRichError } from './get-rich-error'; +import description from './run-command.md'; + +interface Params { + writer: UIMessageStreamWriter>; +} + +export const runCommand = ({ writer }: Params) => + tool({ + description, + inputSchema: z.object({ + sandboxId: z.string().describe('The ID of the Vercel Sandbox to run the command in'), + command: z + .string() + .describe( + "The base command to run (e.g., 'npm', 'node', 'python', 'ls', 'cat'). Do NOT include arguments here. IMPORTANT: Each command runs independently in a fresh shell session - there is no persistent state between commands. You cannot use 'cd' to change directories for subsequent commands.", + ), + args: z + .array(z.string()) + .optional() + .describe( + "Array of arguments for the command. Each argument should be a separate string (e.g., ['install', '--verbose'] for npm install --verbose, or ['src/index.js'] to run a file, or ['-la', './src'] to list files). IMPORTANT: Use relative paths (e.g., 'src/file.js') or absolute paths instead of trying to change directories with 'cd' first, since each command runs in a fresh shell session.", + ), + sudo: z.boolean().optional().describe('Whether to run the command with sudo'), + wait: z + .boolean() + .describe( + 'Whether to wait for the command to finish before returning. If true, the command will block until it completes, and you will receive its output.', + ), + }), + execute: async ({ sandboxId, command, sudo, wait, args = [] }, { toolCallId }) => { + writer.write({ + id: toolCallId, + type: 'data-run-command', + data: { sandboxId, command, args, status: 'executing' }, + }); + + let sandbox: Sandbox | null = null; + + try { + sandbox = await Sandbox.get({ sandboxId }); + } catch (error) { + const richError = getRichError({ + action: 'get sandbox by id', + args: { sandboxId }, + error, + }); + + writer.write({ + id: toolCallId, + type: 'data-run-command', + data: { + sandboxId, + command, + args, + error: richError.error, + status: 'error', + }, + }); + + return richError.message; + } + + let cmd: Command | null = null; + + try { + cmd = await sandbox.runCommand({ + detached: true, + cmd: command, + args, + sudo, + }); + } catch (error) { + const richError = getRichError({ + action: 'run command in sandbox', + args: { sandboxId }, + error, + }); + + writer.write({ + id: toolCallId, + type: 'data-run-command', + data: { + sandboxId, + command, + args, + error: richError.error, + status: 'error', + }, + }); + + return richError.message; + } + + writer.write({ + id: toolCallId, + type: 'data-run-command', + data: { + sandboxId, + commandId: cmd.cmdId, + command, + args, + status: 'executing', + }, + }); + + if (!wait) { + writer.write({ + id: toolCallId, + type: 'data-run-command', + data: { + sandboxId, + commandId: cmd.cmdId, + command, + args, + status: 'running', + }, + }); + + return `The command \`${command} ${args.join( + ' ', + )}\` has been started in the background in the sandbox with ID \`${sandboxId}\` with the commandId ${ + cmd.cmdId + }.`; + } + + writer.write({ + id: toolCallId, + type: 'data-run-command', + data: { + sandboxId, + commandId: cmd.cmdId, + command, + args, + status: 'waiting', + }, + }); + + const done = await cmd.wait(); + try { + const [stdout, stderr] = await Promise.all([done.stdout(), done.stderr()]); + + writer.write({ + id: toolCallId, + type: 'data-run-command', + data: { + sandboxId, + commandId: cmd.cmdId, + command, + args, + exitCode: done.exitCode, + status: 'done', + }, + }); + + return ( + `The command \`${command} ${args.join( + ' ', + )}\` has finished with exit code ${done.exitCode}.` + + `Stdout of the command was: \n` + + `\`\`\`\n${stdout}\n\`\`\`\n` + + `Stderr of the command was: \n` + + `\`\`\`\n${stderr}\n\`\`\`` + ); + } catch (error) { + const richError = getRichError({ + action: 'wait for command to finish', + args: { sandboxId, commandId: cmd.cmdId }, + error, + }); + + writer.write({ + id: toolCallId, + type: 'data-run-command', + data: { + sandboxId, + commandId: cmd.cmdId, + command, + args, + error: richError.error, + status: 'error', + }, + }); + + return richError.message; + } + }, + }); diff --git a/apps/app/src/ai/tools/store-to-s3.ts b/apps/app/src/ai/tools/store-to-s3.ts new file mode 100644 index 000000000..7b2775418 --- /dev/null +++ b/apps/app/src/ai/tools/store-to-s3.ts @@ -0,0 +1,134 @@ +import { s3Client } from '@/app/s3'; +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import type { UIMessage, UIMessageStreamWriter } from 'ai'; +import { tool } from 'ai'; +import z from 'zod/v3'; +import type { DataPart } from '../messages/data-parts'; + +interface Params { + writer: UIMessageStreamWriter>; +} + +const inputSchema = z.object({ + content: z.string().min(1).describe('The full file content to store'), + orgId: z.string().optional().describe('Organization identifier'), + taskId: z.string().optional().describe('Task identifier'), + contentType: z.string().optional().describe('MIME type, defaults to text/plain for generic code'), +}); +interface ToolInput { + content: string; + orgId?: string; + taskId?: string; + contentType?: string; +} + +export const storeToS3 = ({ writer }: Params) => { + const config = { + description: + 'Upload a generated code artifact to S3 for persistence. Use after user confirms the code is good.', + inputSchema, + execute: async (args: unknown, ctx: { toolCallId: string }) => { + const { toolCallId } = ctx; + const parsed: unknown = inputSchema.parse(args); + const input = parsed as ToolInput; + const { content, orgId, taskId, contentType } = input; + + // Validate task format: must export a function via module.exports + // Validate task format: must export a function with only event parameter + // (getSecret and fetch are provided as globals in the Lambda sandbox) + const isTaskFn = + /module\.exports\s*=\s*async\s*\(\s*event\s*\)\s*=>\s*\{/.test(content) || + /module\.exports\s*=\s*\(\s*event\s*\)\s*=>\s*\{/.test(content) || + /module\.exports\s*=\s*async\s*function\s*\(\s*event\s*\)\s*\{/.test(content) || + /module\.exports\s*=\s*function\s*\(\s*event\s*\)\s*\{/.test(content); + + if (!isTaskFn) { + const message = + 'Task module must export a function via module.exports = async (event) => { ... }'; + writer.write({ + id: toolCallId, + type: 'data-store-to-s3', + data: { + status: 'error', + error: { message }, + }, + }); + return message; + } + // Enforce: no usage of process.env in task code + if (/process\.env\b/.test(content)) { + const message = + 'Do not use process.env in task code; use the global getSecret function provided by the Lambda sandbox.'; + writer.write({ + id: toolCallId, + type: 'data-store-to-s3', + data: { + status: 'error', + error: { message }, + }, + }); + return message; + } + const resolvedBucket = process.env.TASKS_AUTOMATION_BUCKET; + const resolvedOrgId = orgId; + const resolvedTaskId = taskId; + const keyBase = `${resolvedOrgId}/${resolvedTaskId}`; + const key = `${keyBase}.automation.js`; + + writer.write({ + id: toolCallId, + type: 'data-store-to-s3', + data: { + status: 'uploading', + bucket: resolvedBucket, + key, + }, + }); + + try { + await s3Client.send( + new PutObjectCommand({ + Bucket: resolvedBucket, + Key: key, + Body: Buffer.from(content, 'utf8'), + ContentType: contentType || 'application/javascript; charset=utf-8', + Metadata: { + runtime: 'nodejs20.x', + handler: 'task-fn', + language: 'javascript', + entry: 'task.js', + packaging: 'task-fn', + filename: key, + }, + }), + ); + + writer.write({ + id: toolCallId, + type: 'data-store-to-s3', + data: { + status: 'done', + bucket: resolvedBucket, + key, + }, + }); + + return `Stored code in s3://${resolvedBucket}/${key}`; + } catch (error) { + const message = (error as Error)?.message || 'Unknown error uploading to S3'; + writer.write({ + id: toolCallId, + type: 'data-store-to-s3', + data: { + status: 'error', + bucket: resolvedBucket, + key, + error: { message }, + }, + }); + return message; + } + }, + } as const; + return tool(config as any); +}; diff --git a/apps/app/src/ai/tools/task-automation-tools.ts b/apps/app/src/ai/tools/task-automation-tools.ts new file mode 100644 index 000000000..7a1ada6f9 --- /dev/null +++ b/apps/app/src/ai/tools/task-automation-tools.ts @@ -0,0 +1,40 @@ +/** + * Task Automation Tools + * + * A limited set of AI tools specifically for task automation. + * Only includes tools necessary for creating and storing automation scripts. + */ + +import type { InferUITools, UIMessage, UIMessageStreamWriter } from 'ai'; +import type { DataPart } from '../messages/data-parts'; +import { exaSearchTool } from './exa-search'; +import { firecrawlTool } from './firecrawl'; +import { promptForInfoTool } from './prompt-for-info'; +import { promptForSecretTool } from './prompt-for-secret'; +import { storeToS3 } from './store-to-s3'; + +interface Params { + modelId: string; + writer: UIMessageStreamWriter>; +} + +/** + * Get task automation specific tools + * Includes: + * - storeToS3: For saving scripts directly + * - promptForSecret: For requesting missing secrets from users + * - promptForInfo: For requesting missing information/parameters from users + * - exaSearch: For searching the web using Exa AI's neural search + * - firecrawl: For crawling and extracting content from websites + */ +export function getTaskAutomationTools({ modelId, writer }: Params) { + return { + storeToS3: storeToS3({ writer }), + promptForSecret: promptForSecretTool(), + promptForInfo: promptForInfoTool(), + exaSearch: exaSearchTool(), + firecrawl: firecrawlTool(), + }; +} + +export type TaskAutomationToolSet = InferUITools>; diff --git a/apps/app/src/app/(app)/[orgId]/components/DynamicMinHeight.tsx b/apps/app/src/app/(app)/[orgId]/components/DynamicMinHeight.tsx index 329a8e580..9ca38b21e 100644 --- a/apps/app/src/app/(app)/[orgId]/components/DynamicMinHeight.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/DynamicMinHeight.tsx @@ -42,7 +42,7 @@ export function DynamicMinHeight({ children, className }: DynamicMinHeightProps) const style = useMemo(() => ({ minHeight: `calc(100vh - ${offsetPx}px)` }), [offsetPx]); return ( -
+
{children}
); diff --git a/apps/app/src/app/(app)/[orgId]/controls/layout.tsx b/apps/app/src/app/(app)/[orgId]/controls/layout.tsx index fc6ca616f..9f7cd2347 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/layout.tsx @@ -1,6 +1,6 @@ export default async function Layout({ children }: { children: React.ReactNode }) { return ( -
+
{children}
); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx index fc6ca616f..9f7cd2347 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx @@ -1,6 +1,6 @@ export default async function Layout({ children }: { children: React.ReactNode }) { return ( -
+
{children}
); diff --git a/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx b/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx index ac568a79c..983c329e5 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx @@ -1,3 +1,3 @@ export default async function Layout({ children }: { children: React.ReactNode }) { - return
{children}
; + return
{children}
; } diff --git a/apps/app/src/app/(app)/[orgId]/people/layout.tsx b/apps/app/src/app/(app)/[orgId]/people/layout.tsx index 79a1e0b92..508fe95f3 100644 --- a/apps/app/src/app/(app)/[orgId]/people/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/layout.tsx @@ -27,7 +27,7 @@ export default async function Layout({ children }: { children: React.ReactNode } }); return ( -
+
+ +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts index 1bcbb8393..ebbf58b83 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts @@ -192,6 +192,7 @@ export const getComments = async (policyId: string): Promise ({ id: att.id, diff --git a/apps/app/src/app/(app)/[orgId]/policies/layout.tsx b/apps/app/src/app/(app)/[orgId]/policies/layout.tsx index 91fee992f..2bc06dc08 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/layout.tsx @@ -9,7 +9,7 @@ export default async function Layout({ children, params }: LayoutProps) { const { orgId } = await params; return ( -
+
{children}
; + return
{children}
; } diff --git a/apps/app/src/app/(app)/[orgId]/settings/layout.tsx b/apps/app/src/app/(app)/[orgId]/settings/layout.tsx index a3a081364..835945cbe 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/layout.tsx @@ -16,7 +16,7 @@ export default async function Layout({ children }: { children: React.ReactNode } } return ( -
+
Loading...
}> diff --git a/apps/app/src/app/(app)/[orgId]/settings/secrets/components/AddSecretDialog.tsx b/apps/app/src/app/(app)/[orgId]/settings/secrets/components/AddSecretDialog.tsx new file mode 100644 index 000000000..d18266981 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/settings/secrets/components/AddSecretDialog.tsx @@ -0,0 +1,203 @@ +'use client'; + +import { Button } from '@comp/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@comp/ui/dialog'; +import { Input } from '@comp/ui/input'; +import { Label } from '@comp/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; +import { Textarea } from '@comp/ui/textarea'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Loader2, Plus } from 'lucide-react'; +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +interface AddSecretDialogProps { + onSecretAdded?: () => void; +} + +const secretSchema = z.object({ + name: z + .string() + .min(1, 'Name is required') + .max(100, 'Name is too long') + .regex(/^[A-Z0-9_]+$/, 'Name must be uppercase letters, numbers, and underscores only'), + value: z.string().min(1, 'Value is required'), + description: z.string().optional(), + category: z.string().optional(), +}); + +type SecretFormValues = z.infer; + +export function AddSecretDialog({ onSecretAdded }: AddSecretDialogProps) { + const [open, setOpen] = useState(false); + + const { + handleSubmit, + control, + register, + reset, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(secretSchema), + defaultValues: { name: '', value: '', description: '', category: '' }, + mode: 'onChange', + }); + + const onSubmit = handleSubmit(async (values) => { + // Get organizationId from the URL path + const pathSegments = window.location.pathname.split('/'); + const orgId = pathSegments[1]; + + try { + const response = await fetch('/api/secrets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: values.name, + value: values.value, + description: values.description || null, + category: values.category || null, + organizationId: orgId, + }), + }); + + if (!response.ok) { + const error = await response.json(); + // Map Zod errors to form fields + if (Array.isArray(error.details)) { + let handled = false; + for (const issue of error.details) { + const field = issue?.path?.[0] as keyof SecretFormValues | undefined; + if (field) { + setError(field, { type: 'server', message: issue.message }); + handled = true; + } + } + if (handled) return; // Inline errors shown; skip toast + } + throw new Error(error.error || 'Failed to create secret'); + } + + toast.success('Secret created successfully'); + setOpen(false); + reset(); + + if (onSecretAdded) onSecretAdded(); + else window.location.reload(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to create secret'); + console.error('Error creating secret:', err); + } + }); + + return ( + + + + + +
+ + Add New Secret + + Create a new secret that can be accessed by AI automations in your organization. + + +
+
+ + + {errors.name?.message ? ( +

{errors.name.message}

+ ) : null} +

+ Use uppercase with underscores for naming convention +

+
+
+ + + {errors.value?.message ? ( +

{errors.value.message}

+ ) : null} +
+
+ + ( + + )} + /> + {errors.category?.message ? ( +

{errors.category.message}

+ ) : null} +
+
+ +