diff --git a/Cargo.lock b/Cargo.lock index f4262236de..b9ffce2777 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4478,6 +4478,7 @@ dependencies = [ "rivet-pools", "rivet-runner-protocol", "rivet-runtime", + "rivet-serverless-backfill", "rivet-service-manager", "rivet-telemetry", "rivet-term", @@ -4739,6 +4740,19 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "rivet-serverless-backfill" +version = "0.1.0" +dependencies = [ + "anyhow", + "gasoline", + "pegboard", + "rivet-config", + "rivet-types", + "tracing", + "universaldb", +] + [[package]] name = "rivet-service-manager" version = "2.0.25" diff --git a/Cargo.toml b/Cargo.toml index 5ea0101e0d..4d31be8acb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" -members = ["engine/packages/actor-kv","engine/packages/api-builder","engine/packages/api-peer","engine/packages/api-public","engine/packages/api-types","engine/packages/api-util","engine/packages/bootstrap","engine/packages/cache","engine/packages/cache-purge","engine/packages/cache-result","engine/packages/clickhouse-inserter","engine/packages/clickhouse-user-query","engine/packages/config","engine/packages/dump-openapi","engine/packages/engine","engine/packages/env","engine/packages/epoxy","engine/packages/error","engine/packages/error-macros","engine/packages/gasoline","engine/packages/gasoline-macros","engine/packages/guard","engine/packages/guard-core","engine/packages/logs","engine/packages/metrics","engine/packages/namespace","engine/packages/pegboard","engine/packages/pegboard-gateway","engine/packages/pegboard-runner","engine/packages/pools","engine/packages/runtime","engine/packages/service-manager","engine/packages/telemetry","engine/packages/test-deps","engine/packages/test-deps-docker","engine/packages/tracing-reconfigure","engine/packages/types","engine/packages/universaldb","engine/packages/universalpubsub","engine/packages/util","engine/packages/util-id","engine/packages/workflow-worker","engine/sdks/rust/api-full","engine/sdks/rust/data","engine/sdks/rust/epoxy-protocol","engine/sdks/rust/runner-protocol","engine/sdks/rust/ups-protocol"] +members = ["engine/packages/actor-kv","engine/packages/api-builder","engine/packages/api-peer","engine/packages/api-public","engine/packages/api-types","engine/packages/api-util","engine/packages/bootstrap","engine/packages/cache","engine/packages/cache-purge","engine/packages/cache-result","engine/packages/clickhouse-inserter","engine/packages/clickhouse-user-query","engine/packages/config","engine/packages/dump-openapi","engine/packages/engine","engine/packages/env","engine/packages/epoxy","engine/packages/error","engine/packages/error-macros","engine/packages/gasoline","engine/packages/gasoline-macros","engine/packages/guard","engine/packages/guard-core","engine/packages/logs","engine/packages/metrics","engine/packages/namespace","engine/packages/pegboard","engine/packages/pegboard-gateway","engine/packages/pegboard-runner","engine/packages/pools","engine/packages/runtime","engine/packages/serverless-backfill","engine/packages/service-manager","engine/packages/telemetry","engine/packages/test-deps","engine/packages/test-deps-docker","engine/packages/tracing-reconfigure","engine/packages/tracing-utils","engine/packages/types","engine/packages/universaldb","engine/packages/universalpubsub","engine/packages/util","engine/packages/util-id","engine/packages/workflow-worker","engine/sdks/rust/api-full","engine/sdks/rust/data","engine/sdks/rust/epoxy-protocol","engine/sdks/rust/runner-protocol","engine/sdks/rust/ups-protocol"] [workspace.package] version = "2.0.25" @@ -83,10 +83,13 @@ tracing = "0.1.40" tracing-core = "0.1" tracing-opentelemetry = "0.29" tracing-slog = "0.2" -vergen = { version = "9.0.4", features = ["build", "cargo", "rustc"] } vergen-gitcl = "1.0.0" reqwest-eventsource = "0.6.0" +[workspace.dependencies.vergen] +version = "9.0.4" +features = ["build","cargo","rustc"] + [workspace.dependencies.sentry] version = "0.45.0" default-features = false @@ -148,7 +151,7 @@ features = ["now"] [workspace.dependencies.clap] version = "4.3" -features = ["derive", "cargo"] +features = ["derive","cargo"] [workspace.dependencies.rivet-term] git = "https://github.com/rivet-dev/rivet-term" @@ -357,6 +360,9 @@ path = "engine/packages/pools" [workspace.dependencies.rivet-runtime] path = "engine/packages/runtime" +[workspace.dependencies.rivet-serverless-backfill] +path = "engine/packages/serverless-backfill" + [workspace.dependencies.rivet-service-manager] path = "engine/packages/service-manager" @@ -425,8 +431,3 @@ debug = false lto = "fat" codegen-units = 1 opt-level = 3 - -# strip = true -# panic = "abort" -# overflow-checks = false -# debug-assertions = false diff --git a/engine/packages/engine/Cargo.toml b/engine/packages/engine/Cargo.toml index 0463022ad3..f1b6d7ebb9 100644 --- a/engine/packages/engine/Cargo.toml +++ b/engine/packages/engine/Cargo.toml @@ -32,6 +32,7 @@ rivet-guard.workspace = true rivet-logs.workspace = true rivet-pools.workspace = true rivet-runtime.workspace = true +rivet-serverless-backfill.workspace = true rivet-service-manager.workspace = true rivet-telemetry.workspace = true rivet-term.workspace = true diff --git a/engine/packages/engine/src/run_config.rs b/engine/packages/engine/src/run_config.rs index 635575993d..a17033164a 100644 --- a/engine/packages/engine/src/run_config.rs +++ b/engine/packages/engine/src/run_config.rs @@ -40,6 +40,12 @@ pub fn config(_rivet_config: rivet_config::Config) -> Result { |config, pools| Box::pin(rivet_cache_purge::start(config, pools)), false, ), + Service::new( + "serverless_backfill", + ServiceKind::Oneshot, + |config, pools| Box::pin(rivet_serverless_backfill::start(config, pools)), + false, + ), ]; Ok(RunConfigData { services }) diff --git a/engine/packages/pegboard/src/ops/runner_config/delete.rs b/engine/packages/pegboard/src/ops/runner_config/delete.rs index 7c19fc0ca4..b3d3c96b64 100644 --- a/engine/packages/pegboard/src/ops/runner_config/delete.rs +++ b/engine/packages/pegboard/src/ops/runner_config/delete.rs @@ -44,12 +44,18 @@ pub async fn pegboard_runner_config_delete(ctx: &OperationCtx, input: &Input) -> // Bump pool when a serverless config is modified if delete_pool { - ctx.signal(crate::workflows::runner_pool::Bump {}) + let res = ctx + .signal(crate::workflows::runner_pool::Bump {}) .to_workflow::() .tag("namespace_id", input.namespace_id) .tag("runner_name", input.name.clone()) + .graceful_not_found() .send() .await?; + + if res.is_none() { + tracing::debug!(namespace_id=?input.namespace_id, name=%input.name, "no runner pool workflow to bump"); + } } Ok(()) diff --git a/engine/packages/pegboard/src/ops/runner_config/upsert.rs b/engine/packages/pegboard/src/ops/runner_config/upsert.rs index 9c4f00ad72..09da081335 100644 --- a/engine/packages/pegboard/src/ops/runner_config/upsert.rs +++ b/engine/packages/pegboard/src/ops/runner_config/upsert.rs @@ -169,12 +169,27 @@ pub async fn pegboard_runner_config_upsert(ctx: &OperationCtx, input: &Input) -> .dispatch() .await?; } else if input.config.affects_pool() { - ctx.signal(crate::workflows::runner_pool::Bump {}) + let res = ctx + .signal(crate::workflows::runner_pool::Bump {}) .to_workflow::() .tag("namespace_id", input.namespace_id) .tag("runner_name", input.name.clone()) + .graceful_not_found() .send() .await?; + + // Backfill + if res.is_none() { + ctx.workflow(crate::workflows::runner_pool::Input { + namespace_id: input.namespace_id, + runner_name: input.name.clone(), + }) + .tag("namespace_id", input.namespace_id) + .tag("runner_name", input.name.clone()) + .unique() + .dispatch() + .await?; + } } Ok(res.endpoint_config_changed) diff --git a/engine/packages/serverless-backfill/Cargo.toml b/engine/packages/serverless-backfill/Cargo.toml new file mode 100644 index 0000000000..a1ad64bf2b --- /dev/null +++ b/engine/packages/serverless-backfill/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "rivet-serverless-backfill" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow.workspace = true +gas.workspace = true +pegboard.workspace = true +rivet-config.workspace = true +rivet-types.workspace = true +tracing.workspace = true +universaldb.workspace = true diff --git a/engine/packages/serverless-backfill/src/lib.rs b/engine/packages/serverless-backfill/src/lib.rs new file mode 100644 index 0000000000..5213a2be43 --- /dev/null +++ b/engine/packages/serverless-backfill/src/lib.rs @@ -0,0 +1,87 @@ +use anyhow::Result; +use futures_util::{StreamExt, TryStreamExt}; +use gas::prelude::*; +use universaldb::options::StreamingMode; +use universaldb::utils::IsolationLevel::*; + +#[tracing::instrument(skip_all)] +pub async fn start(config: rivet_config::Config, pools: rivet_pools::Pools) -> Result<()> { + let cache = rivet_cache::CacheInner::from_env(&config, pools.clone())?; + let ctx = StandaloneCtx::new( + db::DatabaseKv::from_pools(pools.clone()).await?, + config.clone(), + pools, + cache, + "serverless_backfill", + Id::new_v1(config.dc_label()), + Id::new_v1(config.dc_label()), + )?; + + let serverless_data = ctx + .udb()? + .run(|tx| async move { + let tx = tx.with_subspace(pegboard::keys::subspace()); + + let serverless_desired_subspace = pegboard::keys::subspace().subspace( + &rivet_types::keys::pegboard::ns::ServerlessDesiredSlotsKey::entire_subspace(), + ); + + tx.get_ranges_keyvalues( + universaldb::RangeOption { + mode: StreamingMode::WantAll, + ..(&serverless_desired_subspace).into() + }, + // NOTE: This is a snapshot to prevent conflict with updates to this subspace + Snapshot, + ) + .map(|res| { + tx.unpack::(res?.key()) + }) + .try_collect::>() + .await + }) + .custom_instrument(tracing::info_span!("read_serverless_tx")) + .await?; + + if serverless_data.is_empty() { + return Ok(()); + } + + tracing::info!("backfilling serverless"); + + let runner_configs = ctx + .op(pegboard::ops::runner_config::get::Input { + runners: serverless_data + .iter() + .map(|key| (key.namespace_id, key.runner_name.clone())) + .collect(), + bypass_cache: true, + }) + .await?; + + for key in &serverless_data { + if !runner_configs + .iter() + .any(|rc| rc.namespace_id == key.namespace_id) + { + tracing::debug!( + namespace_id=?key.namespace_id, + runner_name=?key.runner_name, + "runner config not found, likely deleted" + ); + continue; + }; + + ctx.workflow(pegboard::workflows::runner_pool::Input { + namespace_id: key.namespace_id, + runner_name: key.runner_name.clone(), + }) + .tag("namespace_id", key.namespace_id) + .tag("runner_name", key.runner_name.clone()) + .unique() + .dispatch() + .await?; + } + + Ok(()) +} diff --git a/engine/packages/universalpubsub/src/chunking.rs b/engine/packages/universalpubsub/src/chunking.rs index 2d276233fc..03be93cf0b 100644 --- a/engine/packages/universalpubsub/src/chunking.rs +++ b/engine/packages/universalpubsub/src/chunking.rs @@ -103,7 +103,7 @@ impl ChunkTracker { .retain(|_, buffer| now.duration_since(buffer.last_chunk_ts) < CHUNK_BUFFER_MAX_AGE); let size_after = self.chunks_in_process.len(); - tracing::debug!( + tracing::trace!( ?size_before, ?size_after, "performed chunk buffer garbage collection" diff --git a/examples/ai-app-builder-freestyle/.env.example b/examples/ai-app-builder-freestyle/.env.example new file mode 100644 index 0000000000..cf53184f97 --- /dev/null +++ b/examples/ai-app-builder-freestyle/.env.example @@ -0,0 +1,17 @@ +FREESTYLE_API_KEY=... +ANTHROPIC_API_KEY=... + +PREVIEW_DOMAIN=thepreviewdomain.com + +# Rivet Configuration +# For local development with embedded engine, do NOT set RIVET_ENDPOINT +# The toNextHandler runs the engine automatically +# For production with external Rivet server, uncomment and set: +# RIVET_ENDPOINT=https://your-rivet-server.com +# RIVET_NAMESPACE= +# RIVET_TOKEN= + +# Client-side Rivet configuration (must be absolute URL) +NEXT_PUBLIC_RIVET_ENDPOINT=http://localhost:3000/api/rivet +NEXT_PUBLIC_RIVET_NAMESPACE= +NEXT_PUBLIC_RIVET_TOKEN= diff --git a/examples/ai-app-builder-freestyle/.gitignore b/examples/ai-app-builder-freestyle/.gitignore new file mode 100644 index 0000000000..2b3e5c52b5 --- /dev/null +++ b/examples/ai-app-builder-freestyle/.gitignore @@ -0,0 +1,47 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# git repositories +/git/ + +dump.rdb \ No newline at end of file diff --git a/examples/ai-app-builder-freestyle/.prettierignore b/examples/ai-app-builder-freestyle/.prettierignore new file mode 100644 index 0000000000..af37bb1c91 --- /dev/null +++ b/examples/ai-app-builder-freestyle/.prettierignore @@ -0,0 +1 @@ +src/resumable-stream \ No newline at end of file diff --git a/examples/ai-app-builder-freestyle/package.json b/examples/ai-app-builder-freestyle/package.json new file mode 100644 index 0000000000..cf5977699d --- /dev/null +++ b/examples/ai-app-builder-freestyle/package.json @@ -0,0 +1,58 @@ +{ + "name": "example-ai-app-builder-freestyle", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", + "dev:backend": "tsx --env-file=.env --watch src/backend/server.ts", + "dev:frontend": "vite", + "build": "vite build", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@ai-sdk/anthropic": "^2.0.0", + "@ai-sdk/mcp": "^0.0.11", + "@modelcontextprotocol/sdk": "^1.24.3", + "@radix-ui/react-dialog": "^1.1.15", + "@rivetkit/react": "workspace:*", + "ai": "^5.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "freestyle-sandboxes": "^0.0.97", + "lucide-react": "^0.487.0", + "openai": "^4.77.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.1.1", + "rivetkit": "workspace:*", + "sonner": "^2.0.3", + "tailwind-merge": "^3.2.0", + "zod": "^3.25.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.3", + "@types/node": "^22.13.9", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "concurrently": "^8.2.2", + "tailwindcss": "^4.1.3", + "tsx": "^3.12.7", + "typescript": "^5.5.2", + "vite": "^5.0.0" + }, + "template": { + "technologies": [ + "react", + "typescript" + ], + "tags": [ + "ai", + "app-builder" + ], + "frontendPort": 5173 + }, + "license": "MIT" +} diff --git a/examples/ai-app-builder-freestyle/public/logos/vscode.svg b/examples/ai-app-builder-freestyle/public/logos/vscode.svg new file mode 100644 index 0000000000..a54dd0e57c --- /dev/null +++ b/examples/ai-app-builder-freestyle/public/logos/vscode.svg @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/ai-app-builder-freestyle/scripts/update-ai-sdk.bash b/examples/ai-app-builder-freestyle/scripts/update-ai-sdk.bash new file mode 100644 index 0000000000..0c31b330a8 --- /dev/null +++ b/examples/ai-app-builder-freestyle/scripts/update-ai-sdk.bash @@ -0,0 +1 @@ +npm i --force ai@beta @ai-sdk/anthropic@beta @ai-sdk/react@beta @ai-sdk/ui-utils@canary mastra@ai-v5 @mastra/core@ai-v5 @mastra/mcp@ai-v5 @mastra/memory@ai-v5 @mastra/pg@ai-v5 \ No newline at end of file diff --git a/examples/ai-app-builder-freestyle/src/backend/actors/index.ts b/examples/ai-app-builder-freestyle/src/backend/actors/index.ts new file mode 100644 index 0000000000..cfc3e70913 --- /dev/null +++ b/examples/ai-app-builder-freestyle/src/backend/actors/index.ts @@ -0,0 +1,7 @@ +import { setup } from "rivetkit"; +import { userApp } from "./user-app"; +import { userAppList } from "./user-app-list"; + +export const registry = setup({ + use: { userApp, userAppList }, +}); diff --git a/examples/ai-app-builder-freestyle/src/backend/actors/user-app-list.ts b/examples/ai-app-builder-freestyle/src/backend/actors/user-app-list.ts new file mode 100644 index 0000000000..3169733225 --- /dev/null +++ b/examples/ai-app-builder-freestyle/src/backend/actors/user-app-list.ts @@ -0,0 +1,74 @@ +import { actor } from "rivetkit"; +import type { registry } from "../actors"; +import { freestyle } from "../freestyle"; + +/** + * UserAppList actor - stores the list of all user apps (for browsing) + * Single instance that tracks all app IDs and handles app creation + */ +export const userAppList = actor({ + state: { + appIds: [] as string[], + }, + + actions: { + /** + * Create a new app - creates git repo, adds the app ID to the list, and initializes the userApp actor + */ + createApp: async ( + c, + { + appId, + name, + templateUrl, + templateId, + }: { + appId: string; + name: string; + templateUrl: string; + templateId: string; + }, + ) => { + const client = c.client(); + + // Create git repository + const repo = await freestyle.createGitRepository({ + name, + public: true, + // source: { + // url: "https://github.com/freestyle-sh/freestyle-next", + // type: "git", + // }, + + // import: + source: { + type: "git", + url: templateUrl, + }, + }); + const gitRepo = repo.repoId; + + // Add the app ID to the list + if (!c.state.appIds.includes(appId)) { + c.state.appIds.push(appId); + } + + // Create the userApp actor with input (the actor is initialized with this data) + // Call getInfo to ensure the actor is fully created before returning + const userAppHandle = client.userApp.getOrCreate([appId], { + createWithInput: { + name, + description: "No description", + gitRepo, + templateId, + }, + }); + // Wait for the actor to be ready by calling an action on it + await userAppHandle.getInfo(); + + return { appId, gitRepo }; + }, + + getAppIds: (c) => c.state.appIds, + }, +}); diff --git a/examples/ai-app-builder-freestyle/src/backend/actors/user-app.ts b/examples/ai-app-builder-freestyle/src/backend/actors/user-app.ts new file mode 100644 index 0000000000..6d5df950e5 --- /dev/null +++ b/examples/ai-app-builder-freestyle/src/backend/actors/user-app.ts @@ -0,0 +1,353 @@ +import { actor } from "rivetkit"; +import type { AppDeployment, UIMessage } from "../../shared/types"; +import type { CoreMessage } from "ai"; +import { sendMessage } from "../ai-service"; +import { requestDevServer as requestDevServerFromFreestyle, freestyle } from "../freestyle"; + +/** + * Input for creating a new UserApp actor + */ +export interface UserAppInput { + name: string; + description: string; + gitRepo: string; + templateId: string; +} + +/** + * State for the UserApp actor + */ +export interface UserAppState { + id: string; + name: string; + description: string; + gitRepo: string; + templateId: string; + createdAt: number; + previewDomain?: string; + freestyleIdentity?: string; + freestyleAccessToken?: string; + freestyleAccessTokenId?: string; + messages: UIMessage[]; + deployments: AppDeployment[]; +} + +/** + * UserApp actor - stores data for a single user app and handles all app operations + * Each app gets its own actor instance keyed by app ID + * + * This actor combines the functionality of: + * - app info, messages, deployments + * - stream/generation status + * - AI chat interactions + * - dev server management (per-app) + */ +export const userApp = actor({ + options: { + actionTimeout: 10 * 60 * 1000, + }, + + // Ephemeral variables for non-serializable objects like AbortController + // Also includes streamStatus and streamLastUpdate which shouldn't persist between restarts + createVars: () => ({ + abortController: null as AbortController | null, + streamStatus: undefined as string | undefined, + streamLastUpdate: 0, + }), + + // Initialize state from input when the actor is created + createState: (c, input: UserAppInput): UserAppState => ({ + // App info - flat properties from input plus derived fields + id: c.key[0] as string, + name: input.name, + description: input.description, + gitRepo: input.gitRepo, + templateId: input.templateId, + createdAt: Date.now(), + // Optional properties are omitted initially and set later when needed: + // previewDomain, freestyleIdentity, freestyleAccessToken, freestyleAccessTokenId + // Chat and deployment state + messages: [], + deployments: [], + }), + + actions: { + // ================== + // App Info Actions + // ================== + getInfo: (c) => ({ + id: c.state.id, + name: c.state.name, + createdAt: c.state.createdAt, + }), + + // ================== + // Message Actions + // ================== + addMessage: (c, message: UIMessage) => { + c.state.messages.push(message); + c.broadcast("newMessage", message); + return message; + }, + + getMessages: (c) => c.state.messages, + + clearMessages: (c) => { + c.state.messages = []; + return { success: true }; + }, + + // ================== + // Deployment Actions + // ================== + addDeployment: (c, deployment: Omit) => { + const appDeployment: AppDeployment = { + ...deployment, + createdAt: Date.now(), + }; + c.state.deployments.push(appDeployment); + return appDeployment; + }, + + getDeployments: (c) => c.state.deployments, + + getAll: (c) => ({ + info: { + id: c.state.id, + name: c.state.name, + createdAt: c.state.createdAt, + gitRepo: c.state.gitRepo, + previewDomain: c.state.previewDomain, + }, + messages: c.state.messages, + deployments: c.state.deployments, + }), + + // ================== + // Stream State Actions + // ================== + getStreamStatus: (c) => { + if (c.vars.streamStatus && Date.now() - c.vars.streamLastUpdate > 15000) { + c.vars.streamStatus = undefined; + } + return c.vars.streamStatus; + }, + + abortStream: (c) => { + // Abort the current AI request if one is running + if (c.vars.abortController) { + c.vars.abortController.abort(); + c.vars.abortController = null; + } + c.broadcast("abort"); + c.vars.streamStatus = undefined; + return { success: true }; + }, + + // ================== + // Chat Agent Actions + // ================== + /** + * Send a chat message and get an AI response. + * This action handles everything internally: + * - Adds the user message to state + * - Sets stream status to running + * - Calls the AI service + * - Adds the assistant message to state + * - Clears stream status + * - Broadcasts newMessage events + */ + sendChatMessage: async ( + c, + { message }: { message: UIMessage }, + ): Promise => { + const gitRepo = c.state.gitRepo; + + // Add user message to state and broadcast (only if not already present) + const existingMessage = c.state.messages.find((m) => m.id === message.id); + if (!existingMessage) { + c.state.messages.push(message); + c.broadcast("newMessage", message); + } + + // Set stream status to running + c.vars.streamStatus = "running"; + c.vars.streamLastUpdate = Date.now(); + + // Create abort controller for this request + const abortController = new AbortController(); + c.vars.abortController = abortController; + + try { + // Get dev server + console.log( + "[appStore.sendChatMessage] Requesting dev server for repo:", + gitRepo, + ); + const devServerResult = await requestDevServerFromFreestyle({ + repoId: gitRepo, + }); + console.log("[appStore.sendChatMessage] Dev server ready:", { + mcpEphemeralUrl: devServerResult.mcpEphemeralUrl, + }); + const { mcpEphemeralUrl, fs } = devServerResult; + + // Convert previous messages to CoreMessage format (excluding the new user message) + // Filter out messages with empty content (can happen from failed/aborted streams) + const previousMessages = c.state.messages.slice(0, -1); + console.log("[appStore.sendChatMessage] Converting messages..."); + const coreMessages: CoreMessage[] = previousMessages + .map((m: UIMessage) => { + const content = m.parts + .map((part) => { + if (part.type === "text") { + return part.text; + } + return ""; + }) + .join(""); + return { role: m.role as "user" | "assistant", content }; + }) + .filter((m) => m.content.trim() !== ""); + console.log( + "[appStore.sendChatMessage] Converted", + coreMessages.length, + "messages", + ); + + // Send message to AI with abort signal + console.log( + "[appStore.sendChatMessage] Calling sendMessage...", + ); + // Create a message ID that will be used for streaming updates + const assistantMessageId = crypto.randomUUID(); + + const response = await sendMessage( + c.key[0] as string, + mcpEphemeralUrl, + fs, + message, + coreMessages, + { + maxSteps: 100, + maxOutputTokens: 64000, + abortSignal: abortController.signal, + onStepUpdate: (text: string) => { + // Broadcast step updates so the frontend can show progress + c.broadcast("stepUpdate", { + id: assistantMessageId, + text, + }); + }, + }, + ); + console.log( + "[appStore.sendChatMessage] sendMessage completed", + { + responseTextLength: response.text?.length || 0, + }, + ); + + // Create assistant message from response (using the same ID from step updates) + const assistantMessage: UIMessage = { + id: assistantMessageId, + role: "assistant", + parts: [ + { + type: "text", + text: response.text, + }, + ], + }; + + // Add assistant message to state and broadcast + c.state.messages.push(assistantMessage); + c.broadcast("newMessage", assistantMessage); + + console.log("[appStore.sendChatMessage] === ACTION COMPLETED ==="); + console.log( + "[appStore.sendChatMessage] Returning assistant message:", + assistantMessage.id, + ); + return assistantMessage; + } catch (err) { + // Check if this was an abort error + if (err instanceof Error && err.name === "AbortError") { + console.log("[appStore.sendChatMessage] Request was aborted"); + const abortedMessage: UIMessage = { + id: crypto.randomUUID(), + role: "assistant", + parts: [ + { + type: "text", + text: "Generation stopped.", + }, + ], + }; + c.state.messages.push(abortedMessage); + c.broadcast("newMessage", abortedMessage); + return abortedMessage; + } + + console.error( + "[appStore.sendChatMessage] Error:", + err, + ); + // Add error message to state and broadcast + const errorMessage: UIMessage = { + id: crypto.randomUUID(), + role: "assistant", + parts: [ + { + type: "text", + text: `Error: ${err instanceof Error ? err.message : "Failed to get AI response"}`, + }, + ], + }; + c.state.messages.push(errorMessage); + c.broadcast("newMessage", errorMessage); + return errorMessage; + } finally { + // Clear stream status and abort controller + c.vars.streamStatus = undefined; + c.vars.abortController = null; + } + }, + + // ================== + // Dev Server Actions + // ================== + requestDevServer: async (c) => { + const result = await requestDevServerFromFreestyle({ + repoId: c.state.gitRepo, + }); + // Only return serializable data - fs is an object with methods that can't be serialized + return { + ephemeralUrl: result.ephemeralUrl, + mcpEphemeralUrl: result.mcpEphemeralUrl, + devCommandRunning: result.devCommandRunning, + installCommandRunning: result.installCommandRunning, + codeServerUrl: result.codeServerUrl, + consoleUrl: result.consoleUrl, + }; + }, + + publishApp: async (c, { domain }: { domain: string }) => { + const result = await freestyle.deployWeb( + { + kind: "git", + url: `https://git.freestyle.sh/${c.state.gitRepo}`, + }, + { + build: true, + domains: [domain], + } + ); + c.state.previewDomain = domain; + return { + domain, + deploymentId: result.deploymentId, + }; + }, + }, +}); diff --git a/examples/ai-app-builder-freestyle/src/backend/ai-service.ts b/examples/ai-app-builder-freestyle/src/backend/ai-service.ts new file mode 100644 index 0000000000..ca24593715 --- /dev/null +++ b/examples/ai-app-builder-freestyle/src/backend/ai-service.ts @@ -0,0 +1,252 @@ +import { streamText, tool, stepCountIs, type Tool, type CoreMessage } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { experimental_createMCPClient as createMCPClient } from "@ai-sdk/mcp"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { z } from "zod"; +import { SYSTEM_MESSAGE } from "./system"; +import { FreestyleDevServerFilesystem } from "freestyle-sandboxes"; +import OpenAI from "openai"; +import type { UIMessage } from "../shared/types"; + +const CLAUDE_SONNET_MODEL = "claude-sonnet-4-20250514"; + +export interface AIServiceOptions { + maxSteps?: number; + maxOutputTokens?: number; + abortSignal?: AbortSignal; + onStepUpdate?: (text: string) => void; + onFinish?: () => void; + onError?: (error: { error: unknown }) => void; +} + +export interface AIResponse { + text: string; + messages: unknown[]; +} + +// Lazily create the OpenAI client for Morph API +let morphClient: OpenAI | null = null; +function getMorphClient(): OpenAI { + if (!morphClient) { + morphClient = new OpenAI({ + apiKey: process.env.MORPH_API_KEY, + baseURL: "https://api.morphllm.com/v1", + }); + } + return morphClient; +} + +// Create the todo tool using AI SDK v5 format +const todoTool = tool({ + description: + "Use the update todo list tool to keep track of the tasks you need to do to accomplish the user's request. You should update the todo list each time you complete an item. You can remove tasks from the todo list, but only if they are no longer relevant or you've finished the user's request completely and they are asking for something else. Make sure to update the todo list each time the user asks you do something new. If they're asking for something new, you should probably just clear the whole todo list and start over with new items. For complex logic, use multiple todos to ensure you get it all right rather than just a single todo for implementing all logic.", + inputSchema: z.object({ + items: z.array( + z.object({ + description: z.string(), + completed: z.boolean(), + }) + ), + }), + execute: async () => { + return {}; + }, +}); + +// Create the morph edit tool using AI SDK v5 format +function createMorphTool(fs: FreestyleDevServerFilesystem) { + return tool({ + description: + "Use this tool to make an edit to an existing file.\n\nThis will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write.\nWhen writing the edit, you should specify each edit in sequence, with the special comment // ... existing code ... to represent unchanged code in between edited lines.\n\nFor example:\n\n// ... existing code ...\nFIRST_EDIT\n// ... existing code ...\nSECOND_EDIT\n// ... existing code ...\nTHIRD_EDIT\n// ... existing code ...\n\nYou should still bias towards repeating as few lines of the original file as possible to convey the change.\nBut, each edit should contain sufficient context of unchanged lines around the code you're editing to resolve ambiguity.\nDO NOT omit spans of pre-existing code (or comments) without using the // ... existing code ... comment to indicate its absence. If you omit the existing code comment, the model may inadvertently delete these lines.\nIf you plan on deleting a section, you must provide context before and after to delete it. If the initial code is ```code \\n Block 1 \\n Block 2 \\n Block 3 \\n code```, and you want to remove Block 2, you would output ```// ... existing code ... \\n Block 1 \\n Block 3 \\n // ... existing code ...```.\nMake sure it is clear what the edit should be, and where it should be applied.\nMake edits to a file in a single edit_file call instead of multiple edit_file calls to the same file. The apply model can handle many distinct edits at once.", + inputSchema: z.object({ + target_file: z.string().describe("The target file to modify."), + instructions: z + .string() + .describe( + "A single sentence instruction describing what you are going to do for the sketched edit. This is used to assist the less intelligent model in applying the edit. Use the first person to describe what you are going to do. Use it to disambiguate uncertainty in the edit." + ), + code_edit: z + .string() + .describe( + "Specify ONLY the precise lines of code that you wish to edit. NEVER specify or write out unchanged code. Instead, represent all unchanged code using the comment of the language you're editing in - example: // ... existing code ..." + ), + }), + execute: async ({ target_file, instructions, code_edit }) => { + let file; + try { + file = await fs.readFile(target_file); + } catch (error) { + throw new Error( + `File not found: ${target_file}. Error message: ${error instanceof Error ? error.message : String(error)}` + ); + } + const response = await getMorphClient().chat.completions.create({ + model: "morph-v3-large", + messages: [ + { + role: "user", + content: `${instructions}\n${file}\n${code_edit}`, + }, + ], + }); + + const finalCode = response.choices[0].message.content; + + if (!finalCode) { + throw new Error("No code returned from Morph API."); + } + await fs.writeFile(target_file, finalCode); + return { success: true }; + }, + }); +} + +// Convert UIMessage to the format expected by generateText +function convertUIMessageToMessages(message: UIMessage): CoreMessage[] { + const content = message.parts + .map((part) => { + if (part.type === "text") { + return part.text; + } + return ""; + }) + .join(""); + + return [{ role: message.role as "user" | "assistant", content }]; +} + +/** + * Send a message to the AI and get a response using vanilla AI SDK + */ +export async function sendMessage( + _appId: string, + mcpUrl: string, + fs: FreestyleDevServerFilesystem, + message: UIMessage, + previousMessages: CoreMessage[], + options?: AIServiceOptions +): Promise { + console.log("[sendMessage] Starting...", { mcpUrl }); + + // Create MCP client for Freestyle dev server tools using Streamable HTTP transport + console.log("[sendMessage] Creating MCP client with Streamable HTTP transport..."); + const httpTransport = new StreamableHTTPClientTransport(new URL(mcpUrl)); + const mcpClient = await createMCPClient({ + transport: httpTransport, + }); + console.log("[sendMessage] MCP client created"); + + try { + // Get MCP tools from the Freestyle dev server + console.log("[sendMessage] Getting MCP tools..."); + const mcpTools = await mcpClient.tools(); + console.log("[sendMessage] MCP tools retrieved", { toolCount: Object.keys(mcpTools).length }); + + // Build the tools object - use type assertion to handle the MCP tools + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tools: Record> = { + update_todo_list: todoTool, + ...mcpTools, + }; + + // Add morph tool if API key is available + if (process.env.MORPH_API_KEY) { + tools.edit_file = createMorphTool(fs); + console.log("[sendMessage] Morph edit_file tool added"); + } + + // Convert the incoming message + const newMessages = convertUIMessageToMessages(message); + console.log("[sendMessage] Message converted", { messageCount: newMessages.length }); + + // Combine previous messages with new message + const allMessages: CoreMessage[] = [...previousMessages, ...newMessages]; + console.log("[sendMessage] All messages combined", { totalMessages: allMessages.length }); + + // Call streamText with the AI SDK + console.log("[sendMessage] Calling streamText..."); + let accumulatedText = ""; + const result = streamText({ + model: anthropic(CLAUDE_SONNET_MODEL), + system: SYSTEM_MESSAGE, + messages: allMessages, + tools, + stopWhen: stepCountIs(options?.maxSteps ?? 100), + maxOutputTokens: options?.maxOutputTokens ?? 64000, + abortSignal: options?.abortSignal, + onStepFinish: (step) => { + console.log("[sendMessage] Step finished", { + textLength: step.text?.length || 0, + toolCallCount: step.toolCalls?.length || 0, + toolResultCount: step.toolResults?.length || 0, + }); + + // Add tool calls to the accumulated text so users can see agent activity + if (step.toolCalls && step.toolCalls.length > 0) { + for (const tc of step.toolCalls) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const args = (tc as any).input ?? (tc as any).args; + if (accumulatedText) accumulatedText += "\n\n"; + accumulatedText += `**Tool Call: ${tc.toolName}**\n\`\`\`json\n${JSON.stringify(args, null, 2)}\n\`\`\``; + } + if (options?.onStepUpdate) { + options.onStepUpdate(accumulatedText); + } + } + + // Add tool results to the accumulated text + if (step.toolResults && step.toolResults.length > 0) { + for (const tr of step.toolResults) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (tr as any).output ?? (tr as any).result; + if (accumulatedText) accumulatedText += "\n\n"; + // Truncate large results for readability + const resultStr = JSON.stringify(result, null, 2); + const truncatedResult = resultStr.length > 500 + ? resultStr.substring(0, 500) + "\n... (truncated)" + : resultStr; + accumulatedText += `**Tool Result: ${tr.toolName}**\n\`\`\`json\n${truncatedResult}\n\`\`\``; + } + if (options?.onStepUpdate) { + options.onStepUpdate(accumulatedText); + } + } + + // Add newline separator between steps if there was text + if (step.text && accumulatedText) { + accumulatedText += "\n\n"; + } + }, + }); + + // Stream text tokens in real-time + for await (const chunk of result.textStream) { + accumulatedText += chunk; + if (options?.onStepUpdate) { + options.onStepUpdate(accumulatedText); + } + } + + // Get final response after stream completes + const finalResponse = await result.response; + console.log("[sendMessage] streamText completed", { textLength: accumulatedText?.length || 0 }); + + options?.onFinish?.(); + + console.log("[sendMessage] Returning result"); + // Return accumulated text (all steps combined) instead of result.text (only last step) + return { + text: accumulatedText, + messages: finalResponse?.messages || [], + }; + } catch (error) { + console.error("[sendMessage] Error occurred:", error); + options?.onError?.({ error }); + throw error; + } finally { + // Always close the MCP client + console.log("[sendMessage] Closing MCP client..."); + await mcpClient.close(); + console.log("[sendMessage] MCP client closed"); + } +} diff --git a/examples/ai-app-builder-freestyle/src/backend/freestyle.ts b/examples/ai-app-builder-freestyle/src/backend/freestyle.ts new file mode 100644 index 0000000000..1e721a7a06 --- /dev/null +++ b/examples/ai-app-builder-freestyle/src/backend/freestyle.ts @@ -0,0 +1,31 @@ +import { FreestyleSandboxes } from "freestyle-sandboxes"; + +// Validate required API keys at startup +const freestyleApiKey = process.env.FREESTYLE_API_KEY; +const anthropicApiKey = process.env.ANTHROPIC_API_KEY; + +if (!freestyleApiKey) { + throw new Error("FREESTYLE_API_KEY environment variable is required"); +} + +if (!anthropicApiKey) { + throw new Error("ANTHROPIC_API_KEY environment variable is required"); +} + +export const freestyle = new FreestyleSandboxes({ apiKey: freestyleApiKey }); + +export async function requestDevServer({ repoId }: { repoId: string }) { + const result = await freestyle.requestDevServer({ + repoId, + }); + + return { + ephemeralUrl: result.ephemeralUrl, + mcpEphemeralUrl: result.mcpEphemeralUrl, + fs: result.fs, + devCommandRunning: result.devCommandRunning, + installCommandRunning: result.installCommandRunning, + codeServerUrl: result.codeServerUrl, + consoleUrl: `${result.ephemeralUrl}/__console`, + }; +} diff --git a/examples/ai-app-builder-freestyle/src/backend/server.ts b/examples/ai-app-builder-freestyle/src/backend/server.ts new file mode 100644 index 0000000000..b2f9974ccd --- /dev/null +++ b/examples/ai-app-builder-freestyle/src/backend/server.ts @@ -0,0 +1,3 @@ +import { registry } from "./actors"; + +registry.start(); diff --git a/examples/ai-app-builder-freestyle/src/backend/system.ts b/examples/ai-app-builder-freestyle/src/backend/system.ts new file mode 100644 index 0000000000..43132a9226 --- /dev/null +++ b/examples/ai-app-builder-freestyle/src/backend/system.ts @@ -0,0 +1,397 @@ +export const SYSTEM_MESSAGE = `You are an AI app builder. Create and modify apps as the user requests. + +# Getting Started + +The first thing you should always do when creating a new app is change the home page to a placeholder so that the user can see that something is happening. Then you should explore the project structure and see what has already been provided to you to build the app. + +All of the code you will be editing is in the \`/examples/react/\` directory. + +# Project Structure + +The codebase is organized as follows (all paths are relative to \`/examples/react/\`): + +- \`src/backend/\` - Backend code + - \`src/backend/actors/index.ts\` - The actor registry that exports the \`setup()\` configuration + - \`src/backend/actors/{actor-name}.ts\` - Individual actor definitions + - \`src/backend/server.ts\` - The Hono server that serves the backend +- \`src/frontend/\` - Frontend React code + - \`src/frontend/App.tsx\` - Main React app component with routing + - \`src/frontend/main.tsx\` - React app entry point + - \`src/frontend/index.html\` - HTML template + - \`src/frontend/pages/\` - Page components + - \`src/frontend/components/\` - Reusable UI components + - \`src/frontend/lib/\` - Utility functions and client setup + - \`src/frontend/styles/\` - CSS styles +- \`src/shared/\` - Shared types and utilities used by both frontend and backend + +--- + +# Frontend (React) + +The frontend is built with React and Vite. It uses \`rivetkit/client\` to communicate with the backend actors. + +## React Project Structure + +- \`src/frontend/App.tsx\` - Main app with React Router routes +- \`src/frontend/pages/\` - Page components (one per route) +- \`src/frontend/components/\` - Reusable UI components +- \`src/frontend/lib/client.ts\` - RivetKit client setup +- \`src/frontend/styles/globals.css\` - Global CSS styles + +## Actor Client Setup + +Create a typed client in \`src/frontend/lib/client.ts\`: + +\`\`\`typescript +import { createClient } from "rivetkit/client"; +import type { registry } from "../../backend/actors"; + +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "http://localhost:6420"; + +export const client = createClient({ + endpoint: BACKEND_URL, +}); +\`\`\` + +**Important:** Use \`rivetkit/client\`, NOT \`@rivetkit/react\`. + +## Getting Actor Handles + +There are three ways to get an actor handle: + +### get() - Get an existing actor + +Use when you know the actor already exists. Throws an error if it doesn't. + +\`\`\`typescript +// Get an existing counter by its key +const handle = client.counter.get(["my-counter"]); +const count = await handle.getCount(); +\`\`\` + +### create() - Create a new actor + +Use when creating a new actor. Throws an error if it already exists. + +\`\`\`typescript +// Create a new game session +const handle = client.gameSession.create(["game-123"]); +await handle.initialize(); +\`\`\` + +### getOrCreate() - Get or create an actor + +Use when you want to get an existing actor or create it if it doesn't exist. This is the most common pattern. + +\`\`\`typescript +// Get or create a user's shopping cart +const handle = client.cart.getOrCreate(["user-456"]); +const items = await handle.getItems(); +\`\`\` + +## Actors with Input (createState) + +When an actor uses \`createState\` to dynamically initialize its state, you must pass input data when creating it. Use \`createWithInput\` option: + +\`\`\`typescript +// Creating an actor with input data +const handle = client.userApp.create([appId], { + createWithInput: { + name: "My App", + userId: "user-123", + }, +}); + +// Or with getOrCreate (input is only used if actor is created) +const handle = client.userApp.getOrCreate([appId], { + createWithInput: { + name: "My App", + userId: "user-123", + }, +}); +\`\`\` + +The input is passed to the \`createState\` function in the actor definition: + +\`\`\`typescript +// In the actor definition +export const userApp = actor({ + createState: (c, input: { name: string; userId: string }) => ({ + id: c.key[0] as string, + name: input.name, + userId: input.userId, + createdAt: Date.now(), + }), + // ... +}); +\`\`\` + +## Stateless Calls (One-off Actions) + +For simple request/response operations, call actions directly on the handle. Each call is independent - no persistent connection is maintained. + +\`\`\`typescript +const handle = client.counter.get(["my-counter"]); +const count = await handle.increment(1); +const info = await handle.getInfo(); +\`\`\` + +**React Example (Stateless):** + +\`\`\`typescript +import { useState } from "react"; +import { client } from "@/lib/client"; + +function TodoList() { + const [todos, setTodos] = useState([]); + const [loading, setLoading] = useState(false); + + // Load data on demand (no real-time updates) + const loadTodos = async () => { + setLoading(true); + const items = await client.todoList.get(["my-list"]).getItems(); + setTodos(items); + setLoading(false); + }; + + const addTodo = async (text: string) => { + await client.todoList.get(["my-list"]).addItem(text); + await loadTodos(); // Manually refresh after changes + }; + + return ( +
+ + {todos.map((todo, i) =>
{todo}
)} +
+ ); +} +\`\`\` + +## Stateful Connections (Real-time Events) + +For real-time subscriptions and events, use \`.connect()\`. This maintains a persistent WebSocket connection. + +\`\`\`typescript +const connection = await client.counter.get(["my-counter"]).connect(); + +// Listen to events +connection.on("newCount", (count: number) => { + console.log("Count updated:", count); +}); + +// Call actions through the connection +await connection.increment(1); + +// Clean up when done +connection.dispose(); +\`\`\` + +Use stateful connections when you need to: +- Receive real-time updates from the actor +- Subscribe to broadcasted events +- Maintain a persistent connection for frequent interactions + +Always call \`connection.dispose()\` when you're done to clean up resources. + +**React Example (Stateful with Real-time Updates):** + +\`\`\`typescript +import { useEffect, useState, useRef } from "react"; +import { client } from "@/lib/client"; + +function ChatRoom({ roomId }: { roomId: string }) { + const [messages, setMessages] = useState([]); + const connectionRef = useRef(null); + + useEffect(() => { + let mounted = true; + + const setup = async () => { + // Establish persistent connection + const connection = await client.chatRoom.get([roomId]).connect(); + if (!mounted) return; + + connectionRef.current = connection; + + // Listen for real-time message broadcasts + connection.on("newMessage", (message: Message) => { + if (mounted) { + setMessages(prev => [...prev, message]); + } + }); + + // Load initial messages + const initialMessages = await connection.getMessages(); + if (mounted) setMessages(initialMessages); + }; + + setup(); + + // Cleanup: dispose connection when component unmounts + return () => { + mounted = false; + connectionRef.current?.dispose(); + }; + }, [roomId]); + + const sendMessage = async (text: string) => { + // Can call through handle (stateless) or connection + await client.chatRoom.get([roomId]).sendMessage(text); + }; + + return ( +
+ {messages.map(msg =>
{msg.text}
)} +
+ ); +} +\`\`\` + +## Tips + +- For games that navigate via arrow keys, you likely want to set the body to overflow hidden so that the page doesn't scroll. +- For games that are computationally intensive to render, you should probably use canvas rather than html. +- It's good to have a way to start the game using the keyboard. It's even better if the keys that you use to control the game can be used to start the game. Like if you use WASD to control the game, pressing W should start the game. This doesn't work in all scenarios, but it's a good rule of thumb. +- If you use arrow keys to navigate, generally it's good to support WASD as well. +- Ensure you understand the game mechanics before you start building the game. If you don't understand the game, ask the user to explain it to you in detail. +- Make the games full screen. Don't make them in a small box with a title about it or something. + +--- + +# Backend (RivetKit Actors) + +All backend logic is implemented using Rivet Actors. Actors are stateful objects that persist data and handle actions. Structure your app with one actor per persistent entity (user, document, game session, etc.). + +## Actor Concepts + +- **\`state\`** - Persistent data that survives restarts and crashes (automatically saved) +- **\`vars\`** - Ephemeral runtime data that is NOT persisted (use for connections, timers, caches) +- **\`actions\`** - Type-safe RPC methods that clients can call + +## Defining an Actor + +Actors are defined using the \`actor()\` function from \`rivetkit\`: + +\`\`\`typescript +import { actor } from "rivetkit"; + +export const counter = actor({ + state: { count: 0 }, + actions: { + increment: (c, x: number) => { + c.state.count += x; + c.broadcast("newCount", c.state.count); + return c.state.count; + }, + }, +}); +\`\`\` + +## Actor Lifecycle Methods + +- **\`createState(c, input)\`** - Dynamically initialize state based on input parameters +- **\`createVars(c)\`** - Setup ephemeral variables (DB connections, timers, caches) +- **\`onCreate(c)\`** - Called once when actor is first created +- **\`onStateChange(c, prevState)\`** - Triggered whenever state is modified + +Example with lifecycle methods: + +\`\`\`typescript +export const userApp = actor({ + createState: (c, input: { name: string; userId: string }) => ({ + id: c.key[0] as string, + name: input.name, + userId: input.userId, + createdAt: Date.now(), + }), + createVars: () => ({ + abortController: null as AbortController | null, + }), + actions: { + getName: (c) => c.state.name, + }, +}); +\`\`\` + +## Actor Context (c) + +The context object \`c\` provides: + +- \`c.state\` - Persistent actor state +- \`c.vars\` - Ephemeral variables (not persisted) +- \`c.key\` - Actor addressing key (array) +- \`c.broadcast(event, data)\` - Send events to all connected clients +- \`c.client()\` - Get a client to call other actors +- \`c.destroy()\` - Permanently delete this actor + +## Broadcasting Events + +Send real-time updates to connected clients: + +\`\`\`typescript +actions: { + addMessage: (c, message: Message) => { + c.state.messages.push(message); + c.broadcast("newMessage", message); // All connected clients receive this + return message; + }, +} +\`\`\` + +## Registering Actors + +Actors must be registered in the registry (\`src/backend/actors/index.ts\`): + +\`\`\`typescript +import { setup } from "rivetkit"; +import { myActor } from "./my-actor"; + +export const registry = setup({ + use: { myActor }, +}); +\`\`\` + +## Calling Other Actors from an Actor + +Use \`c.client()\` to get a typed client inside an actor: + +\`\`\`typescript +actions: { + createSubItem: async (c, data: ItemData) => { + const client = c.client(); + const subItem = client.subItem.create([data.id], { + createWithInput: data, + }); + await subItem.initialize(); + return data.id; + }, +} +\`\`\` + +--- + +# Development Workflow + +When building a feature, build the UI for that feature first and show the user that UI using placeholder data. Prefer building UI incrementally and in small pieces so that the user can see the results as quickly as possible. However, don't make so many small updates that it takes way longer to create the app. It's about balance. Build the application logic/backend logic after the UI is built. Then connect the UI to the logic. + +When you need to change a file, prefer editing it rather than writing a new file in it's place. Please make a commit after you finish a task, even if you have more to build. + +# Quality Assurance + +Frequently run the npm_lint tool so you can fix issues as you go and the user doesn't have to just stare at an error screen for a long time. + +Before you ever ask the user to try something, try curling the page yourself to ensure it's not just an error page. You shouldn't have to rely on the user to tell you when something is obviously broken. + +Sometimes if the user tells you something is broken, they might be wrong. Don't be afraid to ask them to reload the page and try again if you think the issue they're describing doesn't make sense. + +# Communication + +Try to be concise and clear in your responses. If you need to ask the user for more information, do so in a way that is easy to understand. If you need to ask the user to try something, explain why they should try it and what you expect to happen. + +It's common that users won't bother to read everything you write, so if you there's something important you want them to do, make sure to put it last and make it as big as possible. + +# Limitations + +Don't try and generate raster images like pngs or jpegs. That's not possible. +`; diff --git a/examples/ai-app-builder-freestyle/src/frontend/App.tsx b/examples/ai-app-builder-freestyle/src/frontend/App.tsx new file mode 100644 index 0000000000..3197489e10 --- /dev/null +++ b/examples/ai-app-builder-freestyle/src/frontend/App.tsx @@ -0,0 +1,13 @@ +import { Outlet } from "react-router-dom"; +import { Toaster } from "sonner"; + +function App() { + return ( +
+ + +
+ ); +} + +export default App; diff --git a/examples/ai-app-builder-freestyle/src/frontend/components/ShareButton.tsx b/examples/ai-app-builder-freestyle/src/frontend/components/ShareButton.tsx new file mode 100644 index 0000000000..6fe7ff5a40 --- /dev/null +++ b/examples/ai-app-builder-freestyle/src/frontend/components/ShareButton.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Share2Icon, CheckIcon } from "lucide-react"; +import { toast } from "sonner"; + +interface ShareButtonProps { + className?: string; + devServerUrl?: string; +} + +export function ShareButton({ className, devServerUrl }: ShareButtonProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + if (!devServerUrl) { + toast.error("Dev server URL not available yet"); + return; + } + + navigator.clipboard + .writeText(devServerUrl) + .then(() => { + setCopied(true); + toast.success("Link copied to clipboard!"); + setTimeout(() => setCopied(false), 2000); + }) + .catch(() => { + toast.error("Failed to copy link"); + }); + }; + + return ( + + ); +} diff --git a/examples/ai-app-builder-freestyle/src/frontend/components/TopBar.tsx b/examples/ai-app-builder-freestyle/src/frontend/components/TopBar.tsx new file mode 100644 index 0000000000..f57341345f --- /dev/null +++ b/examples/ai-app-builder-freestyle/src/frontend/components/TopBar.tsx @@ -0,0 +1,19 @@ +import { Link } from "react-router-dom"; +import { HomeIcon } from "lucide-react"; + +interface TopBarProps { + appName: string; +} + +export function TopBar({ appName }: TopBarProps) { + return ( +
+
+ + + +

{appName}

+
+
+ ); +} diff --git a/examples/ai-app-builder-freestyle/src/frontend/components/WebView.tsx b/examples/ai-app-builder-freestyle/src/frontend/components/WebView.tsx new file mode 100644 index 0000000000..b67c52c394 --- /dev/null +++ b/examples/ai-app-builder-freestyle/src/frontend/components/WebView.tsx @@ -0,0 +1,114 @@ +import { useRef } from "react"; +import { + FreestyleDevServer, + FreestyleDevServerHandle, +} from "freestyle-sandboxes/react/dev-server"; +import { Button } from "./ui/button"; +import { RefreshCwIcon, TerminalIcon, ExternalLinkIcon } from "lucide-react"; +import { ShareButton } from "./ShareButton"; +import { client } from "@/lib/client"; +import "./loader.css"; + +interface WebViewProps { + repoId: string; + appId: string; + codeServerUrl?: string; + consoleUrl?: string; + vmUrl?: string; +} + +export default function WebView({ repoId, appId, codeServerUrl, consoleUrl, vmUrl }: WebViewProps) { + const devServerRef = useRef(null); + + async function requestDevServer({ repoId }: { repoId: string }) { + const result = await client.userApp.get([appId]).requestDevServer(); + return result; + } + + const inspectorUrl = vmUrl + ? `https://inspect.rivet.dev?t=freestyle&u=${encodeURIComponent(`${vmUrl}/rivet`)}` + : null; + + return ( +
+
+ {inspectorUrl && ( + + )} + {codeServerUrl && ( + + )} + {consoleUrl && ( + + )} + + +
+ { + let status = "Starting VM"; + if (installCommandRunning) { + status = "Installing dependencies"; + } else if (serverStarting) { + status = "Starting dev server"; + } else if (devCommandRunning && iframeLoading) { + status = "Loading preview"; + } else if (iframeLoading) { + status = "Loading"; + } + return ( +
+
+
+ {status} +
+
+
+
+
+
+ ); + }} + /> +
+ ); +} diff --git a/examples/ai-app-builder-freestyle/src/frontend/components/loader.css b/examples/ai-app-builder-freestyle/src/frontend/components/loader.css new file mode 100644 index 0000000000..8f14bf0dbe --- /dev/null +++ b/examples/ai-app-builder-freestyle/src/frontend/components/loader.css @@ -0,0 +1,26 @@ +.loader { + height: 4px; + width: 130px; + --c: no-repeat linear-gradient(hsl(var(--primary)) 0 0); + background: var(--c), var(--c), hsl(var(--muted)); + background-size: 60% 100%; + animation: l16 3s infinite; +} + +@keyframes l16 { + 0% { + background-position: + -150% 0, + -150% 0; + } + 66% { + background-position: + 250% 0, + -150% 0; + } + 100% { + background-position: + 250% 0, + 250% 0; + } +} diff --git a/examples/ai-app-builder-freestyle/src/frontend/components/ui/button.tsx b/examples/ai-app-builder-freestyle/src/frontend/components/ui/button.tsx new file mode 100644 index 0000000000..d7f1c6f0ee --- /dev/null +++ b/examples/ai-app-builder-freestyle/src/frontend/components/ui/button.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( + + + ); + } + + return ( +
+
+ {/* Chat Panel */} +
+ {/* Top Bar */} + + + {/* Messages */} +
+ {messages.map((message) => ( +
+
+ {message.parts.map((part, idx) => + part.type === "text" ? ( + message.role === "user" ? ( +
{part.text}
+ ) : ( +
+ {part.text || ""} +
+ ) + ) : null + )} +
+
+ ))} + {isGenerating &&
Generating...
} +
+
+ + {/* Input */} +
+
+