diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 00000000..9561a827 --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,33 @@ +name: opencode + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + opencode: + if: | + contains(github.event.comment.body, ' /oc') || + startsWith(github.event.comment.body, '/oc') || + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Run opencode + uses: anomalyco/opencode/github@latest + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + model: opencode/grok-code \ No newline at end of file diff --git a/bun.lock b/bun.lock index 36328ada..4377effb 100644 --- a/bun.lock +++ b/bun.lock @@ -66,7 +66,6 @@ "e2b": "^2.9.0", "embla-carousel-react": "^8.6.0", "eslint-config-next": "^16.1.1", - "exa-js": "^2.0.12", "firecrawl": "^4.10.0", "input-otp": "^1.4.2", "jest": "^30.2.0", @@ -76,6 +75,7 @@ "next-themes": "^0.4.6", "npkill": "^0.12.2", "prismjs": "^1.30.0", + "qrcode": "^1.5.4", "random-word-slugs": "^0.1.7", "react": "^19.2.3", "react-day-picker": "^9.13.0", @@ -101,6 +101,7 @@ "@tailwindcss/postcss": "^4.1.18", "@types/node": "^24.10.4", "@types/prismjs": "^1.26.5", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "eslint": "^9.39.2", @@ -1026,6 +1027,8 @@ "@types/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -1350,8 +1353,6 @@ "crc": ["crc@4.3.2", "", { "peerDependencies": { "buffer": ">=6.0.3" }, "optionalPeers": ["buffer"] }, "sha512-uGDHf4KLLh2zsHa8D8hIQ1H/HtFQhyHrc0uhHBcoKGol/Xnb+MPYfUMw7cvON6ze/GUESTudKayDcJC5HnJv1A=="], - "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -1536,8 +1537,6 @@ "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], - "exa-js": ["exa-js@2.0.12", "", { "dependencies": { "cross-fetch": "~4.1.0", "dotenv": "~16.4.7", "openai": "^5.0.1", "zod": "^3.22.0", "zod-to-json-schema": "^3.20.0" } }, "sha512-56ZYm8FLKAh3JXCptr0vlG8f39CZxCl4QcPW9QR4TSKS60PU12pEfuQdf+6xGWwQp+doTgXguCqqzxtvgDTDKw=="], - "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "exit-x": ["exit-x@0.2.2", "", {}, "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ=="], @@ -2042,8 +2041,6 @@ "open-file-explorer": ["open-file-explorer@1.0.2", "", {}, "sha512-U4p+VW5uhtgK5W7qSsRhKioYAHCiTX9PiqV4ZtAFLMGfQ3QhppaEevk8k8+DSjM6rgc1yNIR2nttDuWfdNnnJQ=="], - "openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="], - "openapi-fetch": ["openapi-fetch@0.14.1", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A=="], "openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="], @@ -2732,10 +2729,6 @@ "eslint-plugin-react-hooks/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], - "exa-js/dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], - - "exa-js/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="], diff --git a/convex/importData.ts b/convex/importData.ts index 60f4fef7..d666c25a 100644 --- a/convex/importData.ts +++ b/convex/importData.ts @@ -16,7 +16,8 @@ export const importProject = internalMutation({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), createdAt: v.string(), // ISO date string updatedAt: v.string(), // ISO date string @@ -89,7 +90,8 @@ export const importFragment = internalMutation({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), createdAt: v.string(), updatedAt: v.string(), @@ -130,7 +132,8 @@ export const importFragmentDraft = internalMutation({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), createdAt: v.string(), updatedAt: v.string(), @@ -278,7 +281,8 @@ export const importProjectAction = action({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), createdAt: v.string(), updatedAt: v.string(), @@ -320,7 +324,8 @@ export const importFragmentAction = action({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), createdAt: v.string(), updatedAt: v.string(), @@ -343,7 +348,8 @@ export const importFragmentDraftAction = action({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), createdAt: v.string(), updatedAt: v.string(), diff --git a/convex/sandboxSessions.ts b/convex/sandboxSessions.ts index 55632503..ff52059b 100644 --- a/convex/sandboxSessions.ts +++ b/convex/sandboxSessions.ts @@ -16,7 +16,8 @@ export const create = mutation({ v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") ), autoPauseTimeout: v.optional(v.number()), // Default 10 minutes }, diff --git a/convex/schema.ts b/convex/schema.ts index b0db7577..87dfd8d7 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -6,7 +6,15 @@ export const frameworkEnum = v.union( v.literal("ANGULAR"), v.literal("REACT"), v.literal("VUE"), - v.literal("SVELTE") + v.literal("SVELTE"), + v.literal("EXPO") +); + +export const expoPreviewModeEnum = v.union( + v.literal("web"), + v.literal("expo-go"), + v.literal("android-emulator"), + v.literal("eas-build") ); export const messageRoleEnum = v.union( @@ -115,6 +123,11 @@ export default defineSchema({ files: v.any(), metadata: v.optional(v.any()), framework: frameworkEnum, + expoPreviewMode: v.optional(expoPreviewModeEnum), + expoQrCodeUrl: v.optional(v.string()), + expoVncUrl: v.optional(v.string()), + expoEasBuildUrl: v.optional(v.string()), + expoApkUrl: v.optional(v.string()), createdAt: v.optional(v.number()), updatedAt: v.optional(v.number()), }) diff --git a/convex/usage.ts b/convex/usage.ts index aed54459..dc56aab8 100644 --- a/convex/usage.ts +++ b/convex/usage.ts @@ -9,6 +9,59 @@ const UNLIMITED_POINTS = Number.MAX_SAFE_INTEGER; const DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds const GENERATION_COST = 1; +// Expo-specific limits by tier +export const EXPO_LIMITS = { + free: { + webPreview: true, + expoGo: true, + androidEmulator: false, + easBuild: false, + maxBuildsPerDay: 5, + maxEmulatorMinutes: 0 + }, + pro: { + webPreview: true, + expoGo: true, + androidEmulator: true, + easBuild: true, + maxBuildsPerDay: 50, + maxEmulatorMinutes: 120 // 2 hours per day + }, + enterprise: { + webPreview: true, + expoGo: true, + androidEmulator: true, + easBuild: true, + maxBuildsPerDay: 500, + maxEmulatorMinutes: 600 // 10 hours per day + } +} as const; + +export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build'; +export type UserTier = 'free' | 'pro' | 'enterprise'; + +/** + * Check if user can use a specific Expo preview mode + */ +export function canUseExpoPreviewMode( + tier: UserTier, + mode: ExpoPreviewMode +): boolean { + const limits = EXPO_LIMITS[tier]; + switch (mode) { + case 'web': + return limits.webPreview; + case 'expo-go': + return limits.expoGo; + case 'android-emulator': + return limits.androidEmulator; + case 'eas-build': + return limits.easBuild; + default: + return false; + } +} + /** * Check and consume credits for a generation * Returns true if credits were successfully consumed, false if insufficient credits diff --git a/env.example b/env.example index 040718ab..a9807e8b 100644 --- a/env.example +++ b/env.example @@ -24,9 +24,6 @@ OPENROUTER_BASE_URL="https://openrouter.ai/api/v1" # Cerebras API (Z.AI GLM 4.7 model - ultra-fast inference) CEREBRAS_API_KEY="" # Get from https://cloud.cerebras.ai -# Vercel AI Gateway (fallback for Cerebras rate limits) -VERCEL_AI_GATEWAY_API_KEY="" # Get from https://vercel.com/dashboard/ai-gateway - # Brave Search API (web search for subagent research - optional) BRAVE_SEARCH_API_KEY="" # Get from https://api-dashboard.search.brave.com/app/keys diff --git a/explanations/EXPO_INTEGRATION.md b/explanations/EXPO_INTEGRATION.md new file mode 100644 index 00000000..54a4118e --- /dev/null +++ b/explanations/EXPO_INTEGRATION.md @@ -0,0 +1,206 @@ +# Expo/React Native Integration + +ZapDev supports Expo/React Native for cross-platform mobile app development with multiple preview modes. + +## Overview + +Expo enables building iOS, Android, and web apps from a single codebase using React Native. ZapDev integrates Expo with 4 distinct preview modes to support different development and testing scenarios. + +## Preview Modes + +### 1. Web Preview (Free Tier) +- **Speed:** ~30 seconds +- **Description:** Uses `react-native-web` for fast browser-based preview +- **Limitations:** No native APIs (camera, location, haptics, etc.) +- **Best for:** Quick prototyping, UI development, web-compatible features + +### 2. Expo Go QR Code (Free Tier) +- **Speed:** ~1-2 minutes +- **Description:** Generate a QR code that users scan with the Expo Go app +- **Limitations:** Limited to Expo SDK modules, no custom native code +- **Best for:** Real device testing, sharing demos with stakeholders + +### 3. Android Emulator (Pro Tier) +- **Speed:** ~3-5 minutes +- **Description:** Full Android emulator running in E2B with VNC access +- **Limitations:** Requires Pro subscription, higher resource usage +- **Best for:** Full Android testing, GPU-dependent features, native APIs + +### 4. EAS Build (Pro Tier) +- **Speed:** ~5-15 minutes +- **Description:** Cloud builds via Expo Application Services +- **Output:** Installable APK (Android) or IPA (iOS) files +- **Best for:** Production releases, App Store/Play Store submissions + +## Framework Detection + +ZapDev automatically detects Expo projects from user prompts containing: +- "mobile app", "iOS", "Android" +- "React Native", "Expo" +- "cross-platform", "native app" +- "phone app" + +## AI Prompt Guidelines + +When generating Expo code, the AI follows these rules: + +1. **Components:** Use React Native components (View, Text, TouchableOpacity, etc.) +2. **Styling:** Use `StyleSheet.create()` - NO CSS files, NO className, NO Tailwind +3. **Imports:** `import { View, Text } from 'react-native'` +4. **Entry Point:** `App.tsx` as the root component +5. **Navigation:** Use `expo-router` for multi-screen apps + +### Example Component + +```tsx +import { StyleSheet, View, Text, TouchableOpacity } from 'react-native'; +import { StatusBar } from 'expo-status-bar'; + +export default function App() { + return ( + + Hello Expo + + Press Me + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + }, + button: { + backgroundColor: '#007AFF', + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 8, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); +``` + +## Expo SDK Modules + +### Pre-installed (All Templates) +- `expo-status-bar` - Status bar control +- `expo-font` - Custom fonts +- `expo-linear-gradient` - Gradient backgrounds +- `expo-blur` - Blur effects + +### Available via `npx expo install` +- `expo-camera` - Camera access +- `expo-image-picker` - Photo library/camera capture +- `expo-location` - GPS/location +- `expo-haptics` - Haptic feedback +- `expo-notifications` - Push notifications +- `expo-file-system` - File operations +- `expo-av` - Audio/video playback +- `expo-sensors` - Accelerometer, gyroscope +- `expo-secure-store` - Secure storage +- `expo-sqlite` - Local database + +## Web Compatibility + +When using Web Preview mode, these components are **NOT available**: +- `expo-camera` +- `expo-location` +- `expo-haptics` +- `expo-sensors` +- `expo-notifications` (limited) +- `expo-secure-store` + +### Web Alternatives +- **Camera:** Use `` +- **Location:** Use `navigator.geolocation` +- **Storage:** Use AsyncStorage or localStorage + +## E2B Sandbox Templates + +### zapdev-expo-web +- Base: `node:21-slim` +- Pre-installed: react-native-web, @expo/metro-runtime +- Port: 8081 (Metro bundler) +- Command: `npx expo start --web` + +### zapdev-expo-full +- Base: `node:21-slim` +- Pre-installed: All Expo SDK modules +- Port: 8081 (with tunnel for Expo Go) +- Command: `npx expo start --tunnel` + +### zapdev-expo-android +- Base: `ubuntu:22.04` +- Includes: Android SDK, emulator, VNC server +- Ports: 5900 (VNC), 8081 (Metro), 5555 (ADB) +- Resources: 4 vCPU, 8GB RAM + +## Subscription Tiers + +| Feature | Free | Pro | Enterprise | +|---------|------|-----|------------| +| Web Preview | ✅ | ✅ | ✅ | +| Expo Go (QR) | ✅ | ✅ | ✅ | +| Android Emulator | ❌ | ✅ | ✅ | +| EAS Build | ❌ | ✅ | ✅ | +| Max Builds/Day | 5 | 50 | 500 | +| Emulator Minutes/Day | 0 | 120 | 600 | + +## Environment Variables + +For EAS Build support, add to `.env`: +```bash +EXPO_ACCESS_TOKEN=your_expo_token_here +``` + +Get your token from: https://expo.dev/settings/access-tokens + +## Troubleshooting + +### Web Preview Shows Blank Screen +- Ensure you're using web-compatible components +- Check console for `react-native-web` errors +- Avoid native-only modules + +### Expo Go QR Not Working +- Verify tunnel is running (`--tunnel` flag) +- Check network connectivity +- Ensure Expo Go app is up to date + +### Android Emulator Not Starting +- Requires Pro tier subscription +- VNC may take 30-60s to initialize +- Check if KVM is available on E2B + +### EAS Build Failing +- Verify `EXPO_ACCESS_TOKEN` is set +- Check `eas.json` configuration +- Ensure `app.json` has required fields (slug, version) + +## Example Prompts + +1. "Build a mobile todo app for iOS and Android" +2. "Create a React Native camera app" +3. "Make a cross-platform fitness tracker" +4. "Build an Expo app with location tracking" +5. "Create a mobile social media feed" + +## Related Documentation + +- [Expo Official Docs](https://docs.expo.dev) +- [React Native Docs](https://reactnative.dev) +- [E2B Expo Template](https://e2b.dev/docs/template/examples/expo) diff --git a/package.json b/package.json index 97ca952f..e011d283 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,6 @@ "e2b": "^2.9.0", "embla-carousel-react": "^8.6.0", "eslint-config-next": "^16.1.1", - "firecrawl": "^4.10.0", "input-otp": "^1.4.2", "jest": "^30.2.0", @@ -83,6 +82,7 @@ "next-themes": "^0.4.6", "npkill": "^0.12.2", "prismjs": "^1.30.0", + "qrcode": "^1.5.4", "random-word-slugs": "^0.1.7", "react": "^19.2.3", "react-day-picker": "^9.13.0", @@ -108,6 +108,7 @@ "@tailwindcss/postcss": "^4.1.18", "@types/node": "^24.10.4", "@types/prismjs": "^1.26.5", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "eslint": "^9.39.2", diff --git a/sandbox-templates/expo-android/e2b.Dockerfile b/sandbox-templates/expo-android/e2b.Dockerfile new file mode 100644 index 00000000..06748330 --- /dev/null +++ b/sandbox-templates/expo-android/e2b.Dockerfile @@ -0,0 +1,56 @@ +# Expo Android Emulator Template with VNC +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Install base dependencies +RUN apt-get update && apt-get install -y \ + curl wget git unzip openjdk-17-jdk \ + x11vnc xvfb fluxbox \ + qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils \ + supervisor \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Install Node.js 21 +RUN curl -fsSL https://deb.nodesource.com/setup_21.x | bash - \ + && apt-get install -y nodejs + +# Set up Android SDK +ENV ANDROID_HOME=/opt/android-sdk +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator + +RUN mkdir -p $ANDROID_HOME/cmdline-tools \ + && cd $ANDROID_HOME/cmdline-tools \ + && wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdline-tools.zip \ + && unzip -q cmdline-tools.zip \ + && mv cmdline-tools latest \ + && rm cmdline-tools.zip + +# Accept licenses and install Android SDK components +RUN yes | sdkmanager --licenses > /dev/null 2>&1 || true +RUN sdkmanager "platform-tools" "platforms;android-34" "emulator" "system-images;android-34;google_apis;x86_64" + +# Create AVD (Android Virtual Device) +RUN echo no | avdmanager create avd -n expo_emulator -k "system-images;android-34;google_apis;x86_64" --force + +WORKDIR /home/user + +# Create Expo project +RUN npx create-expo-app@latest . --template blank-typescript --yes + +# Install dependencies +RUN npm install react-dom react-native-web @expo/metro-runtime +RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar expo-camera expo-image-picker expo-location expo-haptics + +# Install global tools +RUN npm install -g @expo/cli eas-cli + +# Copy start script +COPY start_android.sh /start_android.sh +RUN chmod +x /start_android.sh + +# Expose ports: VNC(5900), ADB(5555), Metro(8081), Expo(19000-19002) +EXPOSE 5900 5555 8081 19000 19001 19002 + +CMD ["/start_android.sh"] diff --git a/sandbox-templates/expo-android/e2b.toml b/sandbox-templates/expo-android/e2b.toml new file mode 100644 index 00000000..73ffbe44 --- /dev/null +++ b/sandbox-templates/expo-android/e2b.toml @@ -0,0 +1,15 @@ +# E2B Sandbox Template Configuration for Expo Android Emulator + +# Template name used when creating sandboxes +template_id = "zapdev-expo-android" + +# Dockerfile to build the template +dockerfile = "e2b.Dockerfile" + +# Start command (runs when sandbox starts) +start_cmd = "/start_android.sh" + +# Template resource configuration (higher specs for emulator) +[resources] +cpu_count = 4 +memory_mb = 8192 diff --git a/sandbox-templates/expo-android/start_android.sh b/sandbox-templates/expo-android/start_android.sh new file mode 100644 index 00000000..acdde1f0 --- /dev/null +++ b/sandbox-templates/expo-android/start_android.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +# Start virtual display +echo "[INFO] Starting virtual display..." +Xvfb :99 -screen 0 1280x720x24 & +export DISPLAY=:99 + +# Wait for Xvfb to start +sleep 2 + +# Start window manager +echo "[INFO] Starting window manager..." +fluxbox & + +# Start VNC server +echo "[INFO] Starting VNC server on port 5900..." +x11vnc -display :99 -forever -shared -rfbport 5900 -nopw & + +# Wait for display services +sleep 2 + +# Start Android emulator +echo "[INFO] Starting Android emulator..." +$ANDROID_HOME/emulator/emulator -avd expo_emulator \ + -no-audio \ + -no-boot-anim \ + -gpu swiftshader_indirect \ + -no-snapshot \ + -memory 2048 \ + -cores 2 & + +# Wait for emulator to boot +echo "[INFO] Waiting for emulator to boot..." +adb wait-for-device + +# Wait for boot completion +echo "[INFO] Waiting for boot completion..." +while [[ -z $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r') ]]; do + sleep 2 +done + +echo "[INFO] Emulator ready!" + +# Start Expo Metro bundler with Android +cd /home/user +echo "[INFO] Starting Expo development server..." +npx expo start --android --port 8081 --host 0.0.0.0 diff --git a/sandbox-templates/expo-full/e2b.Dockerfile b/sandbox-templates/expo-full/e2b.Dockerfile new file mode 100644 index 00000000..4c42bb0e --- /dev/null +++ b/sandbox-templates/expo-full/e2b.Dockerfile @@ -0,0 +1,23 @@ +# Expo Full Template (Web + Expo Go support with tunnel) +FROM node:21-slim + +RUN apt-get update && apt-get install -y curl git qrencode && apt-get clean && rm -rf /var/lib/apt/lists/* + +WORKDIR /home/user + +# Create Expo app with TypeScript blank template +RUN npx create-expo-app@latest . --template blank-typescript --yes + +# Install web dependencies +RUN npm install react-dom react-native-web @expo/metro-runtime + +# Install common Expo SDK modules +RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar expo-camera expo-image-picker expo-location expo-haptics + +# Install Expo CLI globally for tunnel support +RUN npm install -g @expo/cli eas-cli + +WORKDIR /home/user + +# Start Metro bundler with tunnel for Expo Go access +CMD ["npx", "expo", "start", "--port", "8081", "--host", "0.0.0.0", "--tunnel"] diff --git a/sandbox-templates/expo-full/e2b.toml b/sandbox-templates/expo-full/e2b.toml new file mode 100644 index 00000000..51a2bc15 --- /dev/null +++ b/sandbox-templates/expo-full/e2b.toml @@ -0,0 +1,15 @@ +# E2B Sandbox Template Configuration for Expo Full (Web + Expo Go) + +# Template name used when creating sandboxes +template_id = "zapdev-expo-full" + +# Dockerfile to build the template +dockerfile = "e2b.Dockerfile" + +# Start command (runs when sandbox starts) +start_cmd = "npx expo start --port 8081 --host 0.0.0.0 --tunnel" + +# Template resource configuration +[resources] +cpu_count = 2 +memory_mb = 2048 diff --git a/sandbox-templates/expo-web/e2b.Dockerfile b/sandbox-templates/expo-web/e2b.Dockerfile new file mode 100644 index 00000000..ef019f10 --- /dev/null +++ b/sandbox-templates/expo-web/e2b.Dockerfile @@ -0,0 +1,20 @@ +# Expo Web Preview Template +FROM node:21-slim + +RUN apt-get update && apt-get install -y curl git && apt-get clean && rm -rf /var/lib/apt/lists/* + +WORKDIR /home/user + +# Create Expo app with TypeScript blank template +RUN npx create-expo-app@latest . --template blank-typescript --yes + +# Install web dependencies +RUN npm install react-dom react-native-web @expo/metro-runtime + +# Install common Expo SDK modules +RUN npx expo install expo-font expo-linear-gradient expo-blur expo-status-bar + +WORKDIR /home/user + +# Start Metro bundler for web on port 8081 +CMD ["npx", "expo", "start", "--web", "--port", "8081", "--host", "0.0.0.0"] diff --git a/sandbox-templates/expo-web/e2b.toml b/sandbox-templates/expo-web/e2b.toml new file mode 100644 index 00000000..6493c7d0 --- /dev/null +++ b/sandbox-templates/expo-web/e2b.toml @@ -0,0 +1,15 @@ +# E2B Sandbox Template Configuration for Expo Web + +# Template name used when creating sandboxes +template_id = "zapdev-expo-web" + +# Dockerfile to build the template +dockerfile = "e2b.Dockerfile" + +# Start command (runs when sandbox starts) +start_cmd = "npx expo start --web --port 8081 --host 0.0.0.0" + +# Template resource configuration +[resources] +cpu_count = 2 +memory_mb = 2048 diff --git a/src/agents/client.ts b/src/agents/client.ts index 6b582504..02f493fb 100644 --- a/src/agents/client.ts +++ b/src/agents/client.ts @@ -1,6 +1,5 @@ import { createOpenAI } from "@ai-sdk/openai"; import { createCerebras } from "@ai-sdk/cerebras"; -import { createGateway } from "ai"; export const openrouter = createOpenAI({ apiKey: process.env.OPENROUTER_API_KEY!, @@ -11,10 +10,6 @@ export const cerebras = createCerebras({ apiKey: process.env.CEREBRAS_API_KEY || "", }); -export const gateway = createGateway({ - apiKey: process.env.VERCEL_AI_GATEWAY_API_KEY || "", -}); - // Cerebras model IDs const CEREBRAS_MODELS = ["zai-glm-4.7"]; @@ -31,7 +26,7 @@ export function getModel( options?: ClientOptions ) { if (isCerebrasModel(modelId) && options?.useGatewayFallback) { - return gateway(modelId); + return openrouter(modelId); } if (isCerebrasModel(modelId)) { return cerebras(modelId); @@ -45,7 +40,7 @@ export function getClientForModel( ) { if (isCerebrasModel(modelId) && options?.useGatewayFallback) { return { - chat: (_modelId: string) => gateway(modelId), + chat: (_modelId: string) => openrouter(modelId), }; } if (isCerebrasModel(modelId)) { diff --git a/src/agents/code-agent.ts b/src/agents/code-agent.ts index 99408762..def222c2 100644 --- a/src/agents/code-agent.ts +++ b/src/agents/code-agent.ts @@ -12,6 +12,7 @@ import { type AgentState, type AgentRunInput, type ModelId, + type ExpoPreviewMode, MODEL_CONFIGS, selectModelForTask, frameworkToConvexEnum, @@ -37,6 +38,9 @@ import { REACT_PROMPT, VUE_PROMPT, SVELTE_PROMPT, + EXPO_PROMPT, + EXPO_WEB_PROMPT, + EXPO_NATIVE_PROMPT, } from "@/prompt"; import { sanitizeTextForDatabase } from "@/lib/utils"; import { filterAIGeneratedFiles } from "@/lib/filter-ai-files"; @@ -111,7 +115,7 @@ const extractSummaryText = (value: string): string => { return trimmed; }; -const getFrameworkPrompt = (framework: Framework): string => { +const getFrameworkPrompt = (framework: Framework, expoPreviewMode?: ExpoPreviewMode): string => { switch (framework) { case "nextjs": return NEXTJS_PROMPT; @@ -123,6 +127,11 @@ const getFrameworkPrompt = (framework: Framework): string => { return VUE_PROMPT; case "svelte": return SVELTE_PROMPT; + case "expo": + // Use appropriate prompt based on preview mode + if (expoPreviewMode === "web") return EXPO_WEB_PROMPT; + if (expoPreviewMode === "android-emulator" || expoPreviewMode === "expo-go") return EXPO_NATIVE_PROMPT; + return EXPO_PROMPT; default: return NEXTJS_PROMPT; } @@ -157,7 +166,7 @@ async function detectFramework(prompt: string): Promise { const detectedFramework = text.trim().toLowerCase(); if ( - ["nextjs", "angular", "react", "vue", "svelte"].includes(detectedFramework) + ["nextjs", "angular", "react", "vue", "svelte", "expo"].includes(detectedFramework) ) { return detectedFramework as Framework; } @@ -557,9 +566,14 @@ export async function* runCodeAgent( const result = streamText({ model: client.chat(selectedModel), providerOptions: useGatewayFallbackForStream ? { - gateway: { - only: ['cerebras'], - } + openai: { + extraBody: { + provider: { + order: ["cerebras"], + allow_fallbacks: false, + }, + }, + }, } : undefined, system: frameworkPrompt, messages, @@ -609,7 +623,7 @@ export async function* runCodeAgent( const canRetry = isRateLimit || isServer; if (!useGatewayFallbackForStream && isRateLimit) { - console.log(`[GATEWAY-FALLBACK] Rate limit hit for ${selectedModel}. Switching to Vercel AI Gateway with Cerebras-only routing...`); + console.log(`[GATEWAY-FALLBACK] Rate limit hit for ${selectedModel}. Switching to OpenRouter with Cerebras provider...`); useGatewayFallbackForStream = true; continue; } @@ -672,9 +686,14 @@ export async function* runCodeAgent( followUpResult = await generateText({ model: client.chat(selectedModel), providerOptions: summaryUseGatewayFallback ? { - gateway: { - only: ['cerebras'], - } + openai: { + extraBody: { + provider: { + order: ["cerebras"], + allow_fallbacks: false, + }, + }, + }, } : undefined, system: frameworkPrompt, messages: [ @@ -705,11 +724,11 @@ export async function* runCodeAgent( } if (isRateLimitError(error) && !summaryUseGatewayFallback) { - console.log(`[GATEWAY-FALLBACK] Rate limit hit for summary. Switching to Vercel AI Gateway...`); + console.log(`[GATEWAY-FALLBACK] Rate limit hit for summary. Switching to OpenRouter...`); summaryUseGatewayFallback = true; } else if (isRateLimitError(error)) { const waitMs = 60_000; - console.log(`[GATEWAY-FALLBACK] Gateway rate limit for summary. Waiting ${waitMs / 1000}s...`); + console.log(`[GATEWAY-FALLBACK] OpenRouter rate limit for summary. Waiting ${waitMs / 1000}s...`); await new Promise(resolve => setTimeout(resolve, waitMs)); } else { const backoffMs = 1000 * Math.pow(2, summaryRetries - 1); diff --git a/src/agents/eas-build.ts b/src/agents/eas-build.ts new file mode 100644 index 00000000..b8f15b6c --- /dev/null +++ b/src/agents/eas-build.ts @@ -0,0 +1,257 @@ +import { Sandbox } from "@e2b/code-interpreter"; +import { getSandbox, runCodeCommand } from "./sandbox-utils"; + +export interface EASBuildConfig { + platform: 'android' | 'ios' | 'all'; + profile: 'development' | 'preview' | 'production'; + expoToken?: string; +} + +export interface EASBuildResult { + buildId: string; + buildUrl: string; + platform: string; + status: 'pending' | 'in-queue' | 'in-progress' | 'finished' | 'errored' | 'canceled'; +} + +export interface EASBuildStatus { + status: 'pending' | 'in-queue' | 'in-progress' | 'finished' | 'errored' | 'canceled'; + downloadUrl?: string; + artifacts?: { + buildUrl?: string; + applicationArchiveUrl?: string; + }; + error?: string; +} + +/** + * Initialize EAS in a sandbox (creates eas.json if it doesn't exist) + */ +export async function initializeEAS(sandbox: Sandbox): Promise { + console.log('[INFO] Initializing EAS configuration...'); + + // Check if eas.json exists + const checkResult = await runCodeCommand(sandbox, 'test -f eas.json && echo "exists"'); + + if (!checkResult.stdout.includes('exists')) { + // Create default eas.json configuration + const easConfig = { + cli: { + version: ">= 13.0.0" + }, + build: { + development: { + developmentClient: true, + distribution: "internal" + }, + preview: { + distribution: "internal", + android: { + buildType: "apk" + } + }, + production: { + autoIncrement: true + } + }, + submit: { + production: {} + } + }; + + // Write eas.json + await sandbox.files.write('/home/user/eas.json', JSON.stringify(easConfig, null, 2)); + console.log('[INFO] Created eas.json configuration'); + } + + // Ensure app.json has required fields for EAS + try { + const appJsonContent = await sandbox.files.read('/home/user/app.json'); + if (typeof appJsonContent === 'string') { + const appJson = JSON.parse(appJsonContent); + + // Ensure required fields exist + if (!appJson.expo) appJson.expo = {}; + if (!appJson.expo.slug) appJson.expo.slug = 'zapdev-app'; + if (!appJson.expo.name) appJson.expo.name = 'ZapDev App'; + if (!appJson.expo.version) appJson.expo.version = '1.0.0'; + + // Add EAS project ID placeholder if not present + if (!appJson.expo.extra) appJson.expo.extra = {}; + if (!appJson.expo.extra.eas) appJson.expo.extra.eas = {}; + + await sandbox.files.write('/home/user/app.json', JSON.stringify(appJson, null, 2)); + console.log('[INFO] Updated app.json for EAS compatibility'); + } + } catch (error) { + console.warn('[WARN] Could not update app.json:', error); + } +} + +/** + * Trigger an EAS Build + */ +export async function triggerEASBuild( + sandboxId: string, + config: EASBuildConfig +): Promise { + const sandbox = await getSandbox(sandboxId); + const expoToken = config.expoToken || process.env.EXPO_ACCESS_TOKEN; + + if (!expoToken) { + throw new Error('EXPO_ACCESS_TOKEN is required for EAS builds. Set it in environment variables.'); + } + + // Initialize EAS if needed + await initializeEAS(sandbox); + + console.log(`[INFO] Triggering EAS build for platform: ${config.platform}, profile: ${config.profile}`); + + // Build the command with proper token handling + const buildCommand = `EXPO_TOKEN="${expoToken}" npx eas-cli build --platform ${config.platform} --profile ${config.profile} --non-interactive --json --no-wait`; + + const result = await runCodeCommand(sandbox, buildCommand); + + if (result.exitCode !== 0) { + console.error('[ERROR] EAS build command failed:', result.stderr); + throw new Error(`EAS build failed: ${result.stderr || result.stdout}`); + } + + try { + // Parse the JSON output from EAS CLI + const output = result.stdout.trim(); + const jsonMatch = output.match(/\[[\s\S]*\]|\{[\s\S]*\}/); + + if (!jsonMatch) { + throw new Error('Could not parse EAS build output'); + } + + const buildData = JSON.parse(jsonMatch[0]); + const build = Array.isArray(buildData) ? buildData[0] : buildData; + + return { + buildId: build.id, + buildUrl: `https://expo.dev/accounts/${build.accountName || 'user'}/projects/${build.projectId || 'project'}/builds/${build.id}`, + platform: build.platform || config.platform, + status: build.status || 'pending' + }; + } catch (parseError) { + console.error('[ERROR] Failed to parse EAS build output:', result.stdout); + throw new Error(`Failed to parse EAS build response: ${parseError instanceof Error ? parseError.message : String(parseError)}`); + } +} + +/** + * Check the status of an EAS build + */ +export async function checkEASBuildStatus( + buildId: string, + expoToken?: string +): Promise { + const token = expoToken || process.env.EXPO_ACCESS_TOKEN; + + if (!token) { + throw new Error('EXPO_ACCESS_TOKEN is required to check build status'); + } + + try { + const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Failed to fetch build status: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + return { + status: data.status, + downloadUrl: data.artifacts?.buildUrl || data.artifacts?.applicationArchiveUrl, + artifacts: data.artifacts, + error: data.error + }; + } catch (error) { + console.error('[ERROR] Failed to check EAS build status:', error); + throw new Error(`Failed to check build status: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Poll for EAS build completion + */ +export async function waitForEASBuild( + buildId: string, + expoToken?: string, + maxWaitMs: number = 15 * 60 * 1000, // 15 minutes default + pollIntervalMs: number = 10000 // 10 seconds +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitMs) { + const status = await checkEASBuildStatus(buildId, expoToken); + + if (status.status === 'finished') { + console.log(`[INFO] EAS build ${buildId} completed successfully`); + return status; + } + + if (status.status === 'errored' || status.status === 'canceled') { + console.error(`[ERROR] EAS build ${buildId} failed with status: ${status.status}`); + throw new Error(`EAS build failed: ${status.error || status.status}`); + } + + console.log(`[DEBUG] EAS build ${buildId} status: ${status.status}`); + await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + } + + throw new Error(`EAS build timed out after ${maxWaitMs / 1000} seconds`); +} + +/** + * Get the download URL for a completed build + */ +export async function getEASBuildDownloadUrl( + buildId: string, + expoToken?: string +): Promise { + const status = await checkEASBuildStatus(buildId, expoToken); + + if (status.status !== 'finished') { + return null; + } + + return status.downloadUrl || null; +} + +/** + * Cancel an in-progress EAS build + */ +export async function cancelEASBuild( + buildId: string, + expoToken?: string +): Promise { + const token = expoToken || process.env.EXPO_ACCESS_TOKEN; + + if (!token) { + throw new Error('EXPO_ACCESS_TOKEN is required to cancel a build'); + } + + try { + const response = await fetch(`https://api.expo.dev/v2/builds/${buildId}/cancel`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json' + } + }); + + return response.ok; + } catch (error) { + console.error('[ERROR] Failed to cancel EAS build:', error); + return false; + } +} diff --git a/src/agents/expo-qr.ts b/src/agents/expo-qr.ts new file mode 100644 index 00000000..8b7d3ccb --- /dev/null +++ b/src/agents/expo-qr.ts @@ -0,0 +1,93 @@ +import QRCode from 'qrcode'; + +/** + * Generate a QR code for Expo Go app to scan + * @param sandboxUrl The sandbox URL (e.g., https://8081-abc123.e2b.dev) + * @returns Base64 data URL of the QR code image + */ +export async function generateExpoGoQR(sandboxUrl: string): Promise { + try { + // Expo Go expects exp:// protocol URLs + const url = new URL(sandboxUrl); + const expoUrl = `exp://${url.host}`; + + // Generate QR code as data URL + const qrDataUrl = await QRCode.toDataURL(expoUrl, { + width: 400, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF' + }, + errorCorrectionLevel: 'M' + }); + + console.log(`[INFO] Generated Expo Go QR code for: ${expoUrl}`); + return qrDataUrl; + } catch (error) { + console.error('[ERROR] Failed to generate Expo Go QR code:', error); + throw new Error(`Failed to generate QR code: ${error instanceof Error ? error.message : String(error)}`); + } +} + +/** + * Get the official Expo QR code service URL + * This uses Expo's hosted service to generate QR codes + * @param sandboxUrl The sandbox URL + * @returns URL to Expo's QR code service + */ +export function getExpoOfficialQRUrl(sandboxUrl: string): string { + const encodedUrl = encodeURIComponent(sandboxUrl); + return `https://qr.expo.dev/development-client?url=${encodedUrl}`; +} + +/** + * Generate QR code for EAS Update (for production apps) + * @param projectId Expo project ID + * @param channel Update channel (e.g., 'preview', 'production') + * @param runtimeVersion The runtime version + * @returns URL to Expo's QR code service for the update + */ +export function getEASUpdateQRUrl( + projectId: string, + channel: string = 'preview', + runtimeVersion?: string +): string { + let url = `https://qr.expo.dev/eas-update?projectId=${projectId}&channel=${channel}`; + if (runtimeVersion) { + url += `&runtimeVersion=${encodeURIComponent(runtimeVersion)}`; + } + return url; +} + +/** + * Generate a deep link URL for Expo Go + * @param sandboxUrl The sandbox URL + * @returns Deep link URL that opens in Expo Go + */ +export function getExpoGoDeepLink(sandboxUrl: string): string { + const url = new URL(sandboxUrl); + return `exp://${url.host}`; +} + +/** + * Check if a URL is accessible (for Expo Go tunnel) + * @param url The URL to check + * @returns Whether the URL is accessible + */ +export async function checkUrlAccessible(url: string): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(url, { + method: 'HEAD', + signal: controller.signal + }); + + clearTimeout(timeoutId); + return response.ok; + } catch { + return false; + } +} diff --git a/src/agents/rate-limit.ts b/src/agents/rate-limit.ts index 64beff40..f276f2b5 100644 --- a/src/agents/rate-limit.ts +++ b/src/agents/rate-limit.ts @@ -211,14 +211,14 @@ export async function* withGatewayFallbackGenerator( const lastError = error instanceof Error ? error : new Error(String(error)); if (isRateLimitError(error) && !triedGateway) { - console.log(`[GATEWAY-FALLBACK] ${context}: Rate limit hit for ${modelId}. Switching to Vercel AI Gateway with Cerebras provider...`); + console.log(`[GATEWAY-FALLBACK] ${context}: Rate limit hit for ${modelId}. Switching to OpenRouter with Cerebras provider...`); triedGateway = true; continue; } if (isRateLimitError(error) && triedGateway) { const waitMs = RATE_LIMIT_WAIT_MS; - console.log(`[GATEWAY-FALLBACK] ${context}: Gateway rate limit hit. Waiting ${waitMs / 1000}s...`); + console.log(`[GATEWAY-FALLBACK] ${context}: OpenRouter rate limit hit. Waiting ${waitMs / 1000}s...`); await new Promise(resolve => setTimeout(resolve, waitMs)); // We've tried both direct and gateway, throw the actual rate limit error throw lastError; diff --git a/src/agents/sandbox-utils.ts b/src/agents/sandbox-utils.ts index 5d36b4fe..b7979805 100644 --- a/src/agents/sandbox-utils.ts +++ b/src/agents/sandbox-utils.ts @@ -1,5 +1,5 @@ import { Sandbox } from "@e2b/code-interpreter"; -import { SANDBOX_TIMEOUT, type Framework } from "./types"; +import { SANDBOX_TIMEOUT, type Framework, type ExpoPreviewMode } from "./types"; const SANDBOX_CACHE = new Map(); const PROJECT_SANDBOX_MAP = new Map(); @@ -307,35 +307,47 @@ export async function readFileFast( } } -export function getE2BTemplate(framework: Framework): string { +export function getE2BTemplate(framework: Framework, expoPreviewMode?: ExpoPreviewMode): string { switch (framework) { case "nextjs": return "zapdev"; case "angular": return "zapdev-angular"; case "react": return "zapdev-react"; case "vue": return "zapdev-vue"; case "svelte": return "zapdev-svelte"; + case "expo": + if (expoPreviewMode === "android-emulator") return "zapdev-expo-android"; + if (expoPreviewMode === "expo-go") return "zapdev-expo-full"; + return "zapdev-expo-web"; // Default to web preview (fastest) default: return "zapdev"; } } -export function getFrameworkPort(framework: Framework): number { +export function getFrameworkPort(framework: Framework, expoPreviewMode?: ExpoPreviewMode): number { switch (framework) { case "nextjs": return 3000; case "angular": return 4200; case "react": case "vue": case "svelte": return 5173; + case "expo": + if (expoPreviewMode === "android-emulator") return 5900; // VNC port + return 8081; // Metro bundler port default: return 3000; } } -export function getDevServerCommand(framework: Framework): string { +export function getDevServerCommand(framework: Framework, expoPreviewMode?: ExpoPreviewMode): string { switch (framework) { case "nextjs": return "npm run dev"; case "angular": return "npm run start -- --host 0.0.0.0 --port 4200"; case "react": case "vue": case "svelte": return "npm run dev -- --host 0.0.0.0 --port 5173"; + case "expo": + if (expoPreviewMode === "web") return "npx expo start --web --port 8081 --host 0.0.0.0"; + if (expoPreviewMode === "expo-go") return "npx expo start --tunnel --port 8081"; + if (expoPreviewMode === "android-emulator") return "/start_android.sh"; + return "npx expo start --web --port 8081 --host 0.0.0.0"; default: return "npm run dev"; } } @@ -408,6 +420,7 @@ export const getFindCommand = (framework: Framework): string => { const ignorePatterns = ["node_modules", ".git", "dist", "build"]; if (framework === "nextjs") ignorePatterns.push(".next"); if (framework === "svelte") ignorePatterns.push(".svelte-kit"); + if (framework === "expo") ignorePatterns.push(".expo"); return `find /home/user -type f -not -path '*/${ignorePatterns.join('/* -not -path */')}/*' 2>/dev/null`; }; diff --git a/src/agents/types.ts b/src/agents/types.ts index aabe3f33..eee88c59 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -1,6 +1,8 @@ export const SANDBOX_TIMEOUT = 60_000 * 60; -export type Framework = "nextjs" | "angular" | "react" | "vue" | "svelte"; +export type Framework = "nextjs" | "angular" | "react" | "vue" | "svelte" | "expo"; + +export type ExpoPreviewMode = "web" | "expo-go" | "android-emulator" | "eas-build"; export interface AgentState { summary: string; @@ -9,6 +11,14 @@ export interface AgentState { summaryRetryCount: number; } +export interface ExpoAgentState extends AgentState { + previewMode: ExpoPreviewMode; + qrCodeUrl?: string; + vncUrl?: string; + easBuildUrl?: string; + apkDownloadUrl?: string; +} + export interface AgentRunInput { projectId: string; value: string; @@ -23,6 +33,11 @@ export interface AgentRunResult { summary: string; sandboxId: string; framework: Framework; + expoPreviewMode?: ExpoPreviewMode; + expoQrCodeUrl?: string; + expoVncUrl?: string; + expoEasBuildUrl?: string; + expoApkUrl?: string; } export const MODEL_CONFIGS = { @@ -145,16 +160,17 @@ export function selectModelForTask( export function frameworkToConvexEnum( framework: Framework -): "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" { +): "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" | "EXPO" { const mapping: Record< Framework, - "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" + "NEXTJS" | "ANGULAR" | "REACT" | "VUE" | "SVELTE" | "EXPO" > = { nextjs: "NEXTJS", angular: "ANGULAR", react: "REACT", vue: "VUE", svelte: "SVELTE", + expo: "EXPO", }; return mapping[framework]; } diff --git a/src/components/ExpoPreviewSelector.tsx b/src/components/ExpoPreviewSelector.tsx new file mode 100644 index 00000000..dbb6a24a --- /dev/null +++ b/src/components/ExpoPreviewSelector.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +export type ExpoPreviewMode = 'web' | 'expo-go' | 'android-emulator' | 'eas-build'; +export type UserTier = 'free' | 'pro' | 'enterprise'; + +interface PreviewOption { + mode: ExpoPreviewMode; + title: string; + description: string; + badge?: string; + buildTime: string; + tier: UserTier; + icon: string; +} + +const PREVIEW_OPTIONS: PreviewOption[] = [ + { + mode: 'web', + title: 'Web Preview', + description: 'Fastest preview using react-native-web', + buildTime: '~30 seconds', + tier: 'free', + icon: '🌐' + }, + { + mode: 'expo-go', + title: 'Expo Go (QR Code)', + description: 'Test on real device via Expo Go app', + buildTime: '~1-2 minutes', + tier: 'free', + icon: '📱' + }, + { + mode: 'android-emulator', + title: 'Android Emulator', + description: 'Full Android emulator with VNC access', + badge: 'Pro', + buildTime: '~3-5 minutes', + tier: 'pro', + icon: '🤖' + }, + { + mode: 'eas-build', + title: 'EAS Build (Production)', + description: 'Cloud builds for App Store/Play Store', + badge: 'Pro', + buildTime: '~5-15 minutes', + tier: 'pro', + icon: '🚀' + } +]; + +interface ExpoPreviewSelectorProps { + onSelect: (mode: ExpoPreviewMode) => void; + userTier?: UserTier; + selectedMode?: ExpoPreviewMode; + className?: string; +} + +export function ExpoPreviewSelector({ + onSelect, + userTier = 'free', + selectedMode, + className +}: ExpoPreviewSelectorProps) { + const [selected, setSelected] = useState(selectedMode ?? 'web'); + + const handleSelect = (mode: ExpoPreviewMode) => { + const option = PREVIEW_OPTIONS.find(o => o.mode === mode); + if (!option) return; + + const tierOrder: Record = { free: 0, pro: 1, enterprise: 2 }; + const isLocked = tierOrder[userTier] < tierOrder[option.tier]; + + if (!isLocked) { + setSelected(mode); + onSelect(mode); + } + }; + + return ( + + {PREVIEW_OPTIONS.map((option) => { + const tierOrder: Record = { free: 0, pro: 1, enterprise: 2 }; + const isLocked = tierOrder[userTier] < tierOrder[option.tier]; + const isSelected = selected === option.mode; + + return ( + handleSelect(option.mode)} + > + + + + {option.icon} + {option.title} + + + {option.badge && ( + + {option.badge} + + )} + {isLocked && ( + + 🔒 + + )} + + + + {option.description} + + + Build time: {option.buildTime} + + + + ); + })} + + ); +} + +export function ExpoPreviewInfo({ mode }: { mode: ExpoPreviewMode }) { + const option = PREVIEW_OPTIONS.find(o => o.mode === mode); + if (!option) return null; + + return ( + + {option.icon} + {option.title} + ({option.buildTime}) + + ); +} + +export { PREVIEW_OPTIONS }; diff --git a/src/lib/frameworks.ts b/src/lib/frameworks.ts index e26259f0..7cb5c022 100644 --- a/src/lib/frameworks.ts +++ b/src/lib/frameworks.ts @@ -341,6 +341,73 @@ export const frameworks: Record = { 'SSG', 'production React' ] + }, + expo: { + slug: 'expo', + name: 'Expo', + title: 'Cross-Platform Mobile Development with Expo & React Native', + description: 'Expo is the easiest way to build iOS, Android, and web apps from a single codebase using React Native. Create production-ready mobile applications with our AI-powered development tools.', + metaDescription: 'Create mobile apps with Expo and React Native using AI. Multiple preview modes: web, Expo Go, Android emulator, and EAS Build for production iOS/Android apps.', + features: [ + 'Cross-Platform (iOS/Android/Web)', + 'Hot Reload & Fast Refresh', + 'Expo SDK Modules', + 'Multiple Preview Modes', + 'EAS Build Integration', + 'Over-the-Air Updates', + 'TypeScript Support', + 'expo-router Navigation' + ], + useCases: [ + 'Mobile-First Applications', + 'Social Media Apps', + 'E-commerce Mobile Apps', + 'Fitness & Health Trackers', + 'Photo & Video Apps', + 'Location-Based Services', + 'Progressive Web Apps' + ], + advantages: [ + 'One Codebase, Three Platforms', + 'Rich Native Module Ecosystem', + 'Fast Development Cycle', + 'Real Device Testing (Expo Go)', + 'Cloud Builds (No Xcode/Android Studio)', + 'Strong Community Support' + ], + icon: '📱', + color: '#000020', + popularity: 85, + ecosystem: [ + { + name: 'Expo Go', + description: 'Instant preview on real devices', + url: '/frameworks/expo/expo-go' + }, + { + name: 'EAS Build', + description: 'Cloud-based iOS/Android builds', + url: '/frameworks/expo/eas-build' + }, + { + name: 'expo-router', + description: 'File-based navigation system', + url: '/frameworks/expo/router' + } + ], + relatedFrameworks: ['react', 'nextjs'], + keywords: [ + 'Expo development', + 'React Native', + 'cross-platform mobile', + 'iOS development', + 'Android development', + 'mobile app framework', + 'Expo SDK', + 'React Native components', + 'EAS Build', + 'mobile development' + ] } }; diff --git a/src/prompt.ts b/src/prompt.ts index b3dd914a..c9b8e467 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -4,5 +4,6 @@ export { ANGULAR_PROMPT } from "./prompts/angular"; export { REACT_PROMPT } from "./prompts/react"; export { VUE_PROMPT } from "./prompts/vue"; export { SVELTE_PROMPT } from "./prompts/svelte"; +export { EXPO_PROMPT, EXPO_WEB_PROMPT, EXPO_NATIVE_PROMPT } from "./prompts/expo"; export { FRAMEWORK_SELECTOR_PROMPT } from "./prompts/framework-selector"; export { NEXTJS_PROMPT as PROMPT } from "./prompts/nextjs"; diff --git a/src/prompts/expo.ts b/src/prompts/expo.ts new file mode 100644 index 00000000..2483cdf1 --- /dev/null +++ b/src/prompts/expo.ts @@ -0,0 +1,263 @@ +import { SHARED_RULES } from "./shared"; + +export const EXPO_SHARED_RULES = ` +Environment: +- Writable file system via createOrUpdateFiles +- Command execution via terminal (use "npm install --yes" or "npx expo install ") +- Read files via readFiles +- Do not modify package.json or lock files directly — install packages using the terminal only +- All files are under /home/user +- Entry point is App.tsx (root component) + +File Safety Rules: +- All CREATE OR UPDATE file paths must be relative (e.g., "App.tsx", "components/Button.tsx") +- NEVER use absolute paths like "/home/user/..." or "/home/user/app/..." +- NEVER include "/home/user" in any file path — this will cause critical errors +- When using readFiles or accessing the file system, you MUST use the actual path (e.g. "/home/user/components/Button.tsx") + +Runtime Execution: +- Development servers are not started manually in this environment +- The Metro bundler is already running +- Use validation commands like "npx expo export:web" to verify your work +- Short-lived commands for type-checking and builds are allowed as needed for testing + +Error Prevention & Code Quality (CRITICAL): +1. MANDATORY Validation Before Completion: + - Run: npx tsc --noEmit (for type checking) + - Fix ANY and ALL TypeScript errors immediately + - Only output after validation passes with no errors + +2. Handle All Errors: Every function must include proper error handling +3. Type Safety: Use TypeScript properly with explicit types + +Instructions: +1. Use React Native components exclusively (View, Text, TouchableOpacity, etc.) +2. Use StyleSheet.create() for ALL styling — NO CSS files, NO className +3. Use Expo SDK modules for native functionality +4. Break complex UIs into multiple components +5. Use TypeScript with proper types +6. You MUST use the createOrUpdateFiles tool to make all file changes +7. You MUST use the terminal tool to install any packages (npx expo install ) +8. Do not print code inline or wrap code in backticks + +Final output (MANDATORY): +After ALL tool calls are complete and the task is finished, you MUST output: + + +A short, high-level summary of what was created or changed. + +`; + +export const EXPO_PROMPT = ` +You are a senior React Native engineer using Expo in a sandboxed environment. + +${EXPO_SHARED_RULES} + +Environment: +- Framework: Expo SDK 52+ with React Native 0.76+ +- Entry file: App.tsx (root component) +- Styling: StyleSheet API (React Native styles) +- Navigation: expo-router (file-based routing) or React Navigation +- Dev port: 8081 (Metro bundler) + +Critical Rules: +1. Use React Native components: View, Text, TouchableOpacity, ScrollView, FlatList, Image, TextInput, etc. +2. Styling MUST use StyleSheet.create() — NO CSS files, NO className, NO Tailwind +3. Import from 'react-native': \`import { View, Text, StyleSheet } from 'react-native'\` +4. Use Expo SDK modules: expo-camera, expo-location, expo-font, expo-image-picker, etc. +5. "use client" is NOT needed (React Native doesn't use this directive) +6. File structure: App.tsx as entry, components/ for reusable components +7. For multi-screen apps: Use expo-router with app/ directory structure + +Styling Example: +\`\`\`tsx +import { StyleSheet, View, Text, TouchableOpacity } from 'react-native'; +import { StatusBar } from 'expo-status-bar'; + +export default function App() { + return ( + + Hello Expo + console.log('Pressed')}> + Press Me + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + }, + button: { + backgroundColor: '#007AFF', + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 8, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, +}); +\`\`\` + +Expo SDK Modules (pre-installed): +- expo-status-bar (status bar control) +- expo-font (custom fonts) +- expo-linear-gradient (gradient backgrounds) +- expo-blur (blur effects) + +Expo SDK Modules (install with npx expo install): +- expo-camera (camera access) +- expo-image-picker (photo library/camera capture) +- expo-location (GPS/location) +- expo-haptics (haptic feedback/vibration) +- expo-notifications (push notifications) +- expo-file-system (file operations) +- expo-av (audio/video playback) +- expo-sensors (accelerometer, gyroscope) +- expo-secure-store (secure storage) +- expo-sqlite (local database) + +Navigation with expo-router: +\`\`\`tsx +// app/_layout.tsx +import { Stack } from 'expo-router'; + +export default function Layout() { + return ; +} + +// app/index.tsx +import { Link } from 'expo-router'; +import { View, Text } from 'react-native'; + +export default function Home() { + return ( + + Home Screen + Go to Details + + ); +} +\`\`\` + +Common Patterns: +1. SafeAreaView for notch handling: \`import { SafeAreaView } from 'react-native-safe-area-context'\` +2. KeyboardAvoidingView for forms with keyboard +3. FlatList for performant scrolling lists +4. ActivityIndicator for loading states +5. Platform.OS for platform-specific code + +Workflow: +1. FIRST: Generate all code files using createOrUpdateFiles +2. THEN: Use terminal to install packages if needed (npx expo install ) +3. FINALLY: Provide describing what you built + +Preview Modes: +- **web**: Fast preview using react-native-web, limited native features +- **expo-go**: Scan QR with Expo Go app for real device testing +- **android-emulator**: Full Android emulator with VNC access +- **eas-build**: Production builds for App Store/Play Store +`; + +export const EXPO_WEB_PROMPT = ` +You are a senior React Native engineer using Expo with WEB PREVIEW mode. + +${EXPO_SHARED_RULES} + +Environment: +- Framework: Expo SDK 52+ with React Native 0.76+ +- Preview Mode: WEB (using react-native-web) +- Entry file: App.tsx (root component) +- Styling: StyleSheet API (React Native styles) +- Dev port: 8081 (Metro bundler web) + +IMPORTANT - Web Compatibility: +Since this is web preview mode, you MUST only use web-compatible components and APIs. + +✅ SAFE for Web (use these): +- View, Text, Image, ScrollView, FlatList +- TouchableOpacity, TouchableHighlight, Pressable +- TextInput, Switch, ActivityIndicator +- StyleSheet, Dimensions, Platform +- expo-linear-gradient, expo-blur +- expo-font (web fonts) +- expo-status-bar (no-op on web) + +❌ NOT Available on Web (avoid these): +- expo-camera (use file input instead) +- expo-location (use Geolocation API if needed) +- expo-haptics (no haptic on web) +- expo-sensors (no accelerometer/gyroscope on web) +- expo-notifications (limited on web) +- expo-secure-store (use localStorage) +- Native-only modules + +Web Alternatives: +- Camera: Use \`\` +- Location: Use \`navigator.geolocation\` if needed +- Storage: Use AsyncStorage (works on web) or localStorage +- Vibration: Skip or use Web Vibration API + +Critical Rules: +1. Use React Native components: View, Text, TouchableOpacity, etc. +2. Styling MUST use StyleSheet.create() — NO CSS files, NO className +3. Always check Platform.OS if using platform-specific code +4. Test works on web before completing + +${EXPO_PROMPT.split('Workflow:')[1]} +`; + +export const EXPO_NATIVE_PROMPT = ` +You are a senior React Native engineer using Expo with NATIVE PREVIEW mode. + +${EXPO_SHARED_RULES} + +Environment: +- Framework: Expo SDK 52+ with React Native 0.76+ +- Preview Mode: NATIVE (Android Emulator or Expo Go) +- Entry file: App.tsx (root component) +- Styling: StyleSheet API (React Native styles) +- Full native API access available + +Full Native Access: +You have access to ALL Expo SDK modules and native APIs: +- expo-camera (full camera control) +- expo-location (GPS, background location) +- expo-haptics (haptic feedback) +- expo-sensors (accelerometer, gyroscope, magnetometer) +- expo-notifications (push notifications) +- expo-contacts (address book) +- expo-calendar (calendar events) +- expo-media-library (photo/video library) +- expo-audio (audio recording/playback) +- expo-video (video playback) +- expo-bluetooth-low-energy (BLE) + +Native-Specific Patterns: +1. Use SafeAreaView for proper notch handling +2. Use KeyboardAvoidingView with behavior="padding" for iOS +3. Use StatusBar component for status bar styling +4. Use BackHandler for Android back button +5. Use Linking for deep links + +Performance Tips: +- Use FlatList instead of ScrollView for long lists +- Use useMemo/useCallback for expensive operations +- Use Image.prefetch for remote images +- Use react-native-reanimated for smooth animations + +${EXPO_PROMPT.split('Workflow:')[1]} +`; diff --git a/src/prompts/framework-selector.ts b/src/prompts/framework-selector.ts index 9dba3be0..66183843 100644 --- a/src/prompts/framework-selector.ts +++ b/src/prompts/framework-selector.ts @@ -1,5 +1,5 @@ export const FRAMEWORK_SELECTOR_PROMPT = ` -You are a framework selection expert. Your job is to analyze the user's request and determine the most appropriate web framework to use. +You are a framework selection expert. Your job is to analyze the user's request and determine the most appropriate framework to use. Available frameworks: 1. **nextjs** - Next.js 15 with React, Shadcn UI, and Tailwind CSS @@ -27,9 +27,16 @@ Available frameworks: - Pre-installed: DaisyUI (Tailwind components), Tailwind CSS - Use when: User mentions "Svelte", "SvelteKit", or emphasizes performance +6. **expo** - Expo/React Native with TypeScript + - Best for: Cross-platform mobile apps (iOS + Android + Web), native mobile features + - Pre-installed: Expo SDK, React Native components, TypeScript + - Preview modes: Web (fast), Expo Go (QR code), Android Emulator (VNC), EAS Build (production) + - Use when: User mentions "Expo", "React Native", "mobile app", "iOS", "Android", "cross-platform", "native app", "phone app", or wants to build for mobile devices + Selection Guidelines: - If the user explicitly mentions a framework name, choose that framework -- If the request is ambiguous or doesn't specify, default to **nextjs** (most versatile) +- If the request is for a MOBILE APP (iOS, Android, phone, native app), choose **expo** +- If the request is ambiguous or doesn't specify and is for WEB, default to **nextjs** (most versatile) - Consider the complexity: enterprise/complex = Angular, simple = React/Vue/Svelte - Consider the UI needs: Material Design = Angular or Vue, flexible = Next.js or React - Consider performance emphasis: Svelte for highest performance requirements @@ -41,6 +48,7 @@ You MUST respond with ONLY ONE of these exact strings (no explanation, no markdo - react - vue - svelte +- expo Examples: User: "Build a Netflix clone" @@ -64,5 +72,23 @@ Response: nextjs User: "Create a Material Design admin panel" Response: angular +User: "Build a mobile todo app for iOS and Android" +Response: expo + +User: "Create a React Native camera app" +Response: expo + +User: "Make a cross-platform fitness tracker" +Response: expo + +User: "Build an app for my phone" +Response: expo + +User: "Create a native mobile application" +Response: expo + +User: "Build an Expo app with location tracking" +Response: expo + Now analyze the user's request and respond with ONLY the framework name. `; diff --git a/tests/gateway-fallback.test.ts b/tests/gateway-fallback.test.ts index 342e072a..4ec40a8c 100644 --- a/tests/gateway-fallback.test.ts +++ b/tests/gateway-fallback.test.ts @@ -1,7 +1,7 @@ import { getModel, getClientForModel, isCerebrasModel } from '../src/agents/client'; import { withGatewayFallbackGenerator } from '../src/agents/rate-limit'; -describe('Vercel AI Gateway Fallback', () => { +describe('OpenRouter Fallback', () => { describe('Client Functions', () => { it('should identify Cerebras models correctly', () => { expect(isCerebrasModel('zai-glm-4.7')).toBe(true); @@ -15,21 +15,21 @@ describe('Vercel AI Gateway Fallback', () => { expect(model).not.toBeNull(); }); - it('should return Vercel AI Gateway client when useGatewayFallback is true for Cerebras models', () => { + it('should return OpenRouter client when useGatewayFallback is true for Cerebras models', () => { const model = getModel('zai-glm-4.7', { useGatewayFallback: true }); expect(model).toBeDefined(); expect(model).not.toBeNull(); }); - it('should not use gateway for non-Cerebras models', () => { + it('should not use fallback for non-Cerebras models', () => { expect(isCerebrasModel('anthropic/claude-haiku-4.5')).toBe(false); const directClient = getModel('anthropic/claude-haiku-4.5'); - const gatewayClient = getModel('anthropic/claude-haiku-4.5', { useGatewayFallback: true }); + const fallbackClient = getModel('anthropic/claude-haiku-4.5', { useGatewayFallback: true }); // Both should use the same openrouter provider since non-Cerebras models // don't use gateway fallback - this verifies the stated behavior - expect(directClient.provider).toBe(gatewayClient.provider); + expect(directClient.provider).toBe(fallbackClient.provider); }); it('should return chat function from getClientForModel', () => { @@ -39,7 +39,7 @@ describe('Vercel AI Gateway Fallback', () => { }); }); - describe('Gateway Fallback Generator', () => { + describe('Fallback Generator', () => { it('should yield values from successful generator', async () => { const mockGenerator = async function* () { yield 'value1'; @@ -62,8 +62,7 @@ describe('Vercel AI Gateway Fallback', () => { const mockGenerator = async function* () { attemptCount++; if (attemptCount === 1) { - const error = new Error('Rate limit exceeded'); - (error as any).status = 429; + const error = Object.assign(new Error('Rate limit exceeded'), { status: 429 }); throw error; } yield 'success'; @@ -81,15 +80,13 @@ describe('Vercel AI Gateway Fallback', () => { expect(attemptCount).toBe(2); }); - it('should switch to gateway on rate limit error', async () => { - let useGatewayFlag = false; + it('should switch to OpenRouter on rate limit error', async () => { const mockGenerator = async function* (useGateway: boolean) { if (!useGateway) { - const error = new Error('Rate limit exceeded'); - (error as any).status = 429; + const error = Object.assign(new Error('Rate limit exceeded'), { status: 429 }); throw error; } - yield 'gateway-success'; + yield 'openrouter-success'; }; const values: string[] = []; @@ -100,7 +97,7 @@ describe('Vercel AI Gateway Fallback', () => { values.push(value); } - expect(values).toEqual(['gateway-success']); + expect(values).toEqual(['openrouter-success']); }); it('should throw after max attempts', async () => { @@ -126,7 +123,7 @@ describe('Vercel AI Gateway Fallback', () => { } expect(errorThrown).toBe(true); - expect(attemptCount).toBe(2); // Direct + Gateway attempts + expect(attemptCount).toBe(2); // Direct + fallback attempts }, 10000); // Increase timeout to 10s for safety });
+ {option.description} +
+ Build time: {option.buildTime} +