From 27d3577e76941b4757304442bbe0a026156f5d07 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 19:15:40 +0000 Subject: [PATCH] Replace OpenRouter with Puter AI for theme generation and metadata - Add Puter AI configuration to config/services.php and .env.example - Refactor AiService.php to use Puter's OpenAI-compatible REST API - Update AiThemeGenerator.tsx to use @heyputer/puter.js library directly - Ensure model name is configurable via PUTER_MODEL and VITE_PUTER_MODEL - Update feature tests to mock Puter AI endpoints instead of OpenRouter - Install missing cmdk dependency for frontend build Co-authored-by: claudemyburgh <6057076+claudemyburgh@users.noreply.github.com> --- .env.example | 5 +- app/Services/AiService.php | 20 +- config/services.php | 5 + package-lock.json | 182 ++++++++++++++++++ package.json | 1 + .../components/themes/ai-theme-generator.tsx | 79 ++++++-- tests/Feature/AiDescriptionTest.php | 6 +- tests/Feature/ThemesControllerTest.php | 12 +- 8 files changed, 271 insertions(+), 39 deletions(-) diff --git a/.env.example b/.env.example index 654deba..6e4dc23 100644 --- a/.env.example +++ b/.env.example @@ -76,5 +76,6 @@ PADDLE_PRICE_PRO_YEARLY= VITE_PADDLE_PRICE_PRO_MONTHLY="${PADDLE_PRICE_PRO_MONTHLY}" VITE_PADDLE_PRICE_PRO_YEARLY="${PADDLE_PRICE_PRO_YEARLY}" -OPENROUTER_API_KEY= -OPENROUTER_MODEL=nvidia/nemotron-3-super-120b-a12b:free +PUTER_AUTH_TOKEN= +PUTER_MODEL=gpt-4o-mini +VITE_PUTER_MODEL="${PUTER_MODEL}" diff --git a/app/Services/AiService.php b/app/Services/AiService.php index 29cf69f..4127323 100644 --- a/app/Services/AiService.php +++ b/app/Services/AiService.php @@ -10,7 +10,7 @@ class AiService { public function generateFullTheme(string $prompt): ?array { - $apiKey = config('services.openrouter.key'); + $apiKey = config('services.puter.key'); if (! $apiKey) { return null; @@ -42,10 +42,8 @@ public function generateFullTheme(string $prompt): ?array $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$apiKey, - 'HTTP-Referer' => config('app.url'), - 'X-Title' => config('app.name'), - ])->timeout(30)->post('https://openrouter.ai/api/v1/chat/completions', [ - 'model' => config('services.openrouter.model'), + ])->timeout(30)->post('https://api.puter.com/puterai/openai/v1/chat/completions', [ + 'model' => config('services.puter.model'), 'messages' => [ ['role' => 'system', 'content' => $systemPrompt], ['role' => 'user', 'content' => $prompt], @@ -54,7 +52,7 @@ public function generateFullTheme(string $prompt): ?array ]); if ($response->failed()) { - Log::warning('OpenRouter generateFullTheme failed', [ + Log::warning('Puter generateFullTheme failed', [ 'status' => $response->status(), 'body' => $response->body(), ]); @@ -87,7 +85,7 @@ public function generateFullTheme(string $prompt): ?array */ public function generateThemeMetadata(string $name, array $colors): array { - $apiKey = config('services.openrouter.key'); + $apiKey = config('services.puter.key'); if (! $apiKey) { return ['description' => null, 'tags' => []]; @@ -104,10 +102,8 @@ public function generateThemeMetadata(string $name, array $colors): array $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$apiKey, - 'HTTP-Referer' => config('app.url'), - 'X-Title' => config('app.name'), - ])->timeout(15)->post('https://openrouter.ai/api/v1/chat/completions', [ - 'model' => config('services.openrouter.model'), + ])->timeout(15)->post('https://api.puter.com/puterai/openai/v1/chat/completions', [ + 'model' => config('services.puter.model'), 'messages' => [ [ 'role' => 'user', @@ -118,7 +114,7 @@ public function generateThemeMetadata(string $name, array $colors): array ]); if ($response->failed()) { - Log::warning('OpenRouter generateThemeMetadata failed', [ + Log::warning('Puter generateThemeMetadata failed', [ 'status' => $response->status(), 'body' => $response->body(), ]); diff --git a/config/services.php b/config/services.php index 1b82b7f..2a99aa1 100644 --- a/config/services.php +++ b/config/services.php @@ -51,4 +51,9 @@ 'model' => env('OPENROUTER_MODEL', 'openrouter/free'), ], + 'puter' => [ + 'key' => env('PUTER_AUTH_TOKEN'), + 'model' => env('PUTER_MODEL', 'gpt-4o-mini'), + ], + ]; diff --git a/package-lock.json b/package-lock.json index f80e1e4..b250120 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "@fontsource/bebas-neue": "^5.2.7", "@gsap/react": "^2.1.2", "@headlessui/react": "^2.2.10", + "@heyputer/puter.js": "^2.3.2", "@inertiajs/react": "^3.1.1", "@inertiajs/vite": "^3.1.1", "@monaco-editor/react": "^4.7.0", @@ -31,6 +32,7 @@ "@vitejs/plugin-react": "^5.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "concurrently": "^9.2.1", "culori": "^4.0.2", "date-fns": "^4.1.0", @@ -710,6 +712,22 @@ "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/@heyputer/kv.js": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@heyputer/kv.js/-/kv.js-0.2.1.tgz", + "integrity": "sha512-YhVtzz7ZA/HmuaDvzZZhhUyQWBvp3/TXeY4jULssTdLJwT+tEM4BTYHXttORX+V5auvrYinjj8dNFQnby5T82w==", + "license": "MIT" + }, + "node_modules/@heyputer/puter.js": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@heyputer/puter.js/-/puter.js-2.3.2.tgz", + "integrity": "sha512-NjMCwFnX92gik1Mbl/fxBBWZl3Tzqb3HYKJbYhBchaJBuEUq9A78ntLjoGKWkmZz8M9LzYTmF5xMgeytFgD5Ig==", + "license": "Apache-2.0", + "dependencies": { + "@heyputer/kv.js": "^0.2.1", + "open": "^10.2.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -6303,6 +6321,21 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -6446,6 +6479,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6752,6 +6801,34 @@ "dev": true, "license": "MIT" }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -6770,6 +6847,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -8175,6 +8264,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -8243,6 +8347,24 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -8431,6 +8553,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -9231,6 +9368,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openai": { "version": "6.38.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.38.0.tgz", @@ -10141,6 +10296,18 @@ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -11264,6 +11431,21 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 8346bfa..af3aa8a 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@vitejs/plugin-react": "^5.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "concurrently": "^9.2.1", "culori": "^4.0.2", "date-fns": "^4.1.0", diff --git a/resources/js/components/themes/ai-theme-generator.tsx b/resources/js/components/themes/ai-theme-generator.tsx index faf458d..f545e49 100644 --- a/resources/js/components/themes/ai-theme-generator.tsx +++ b/resources/js/components/themes/ai-theme-generator.tsx @@ -1,42 +1,89 @@ -import { Sparkles, Loader2 } from 'lucide-react'; +import puter from '@heyputer/puter.js'; +import { Loader2, Sparkles } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { generate } from '@/routes/themes'; interface AiThemeGeneratorProps { onGenerated: (data: any) => void; } -export default function AiThemeGenerator({ onGenerated }: AiThemeGeneratorProps) { +const SYSTEM_PROMPT = `You are a UI theme designer for shadcn/ui themes. Generate a complete theme based on the user's description. + +Return valid JSON with these exact keys: +- "title": A human-readable theme title +- "description": A short, engaging description (max 2 sentences) +- "tags": An array of 2 to 6 relevant style tags +- "font_family": A Google Font name (e.g. "Inter", "JetBrains Mono") +- "vars_light": Object with HSL color values for light mode +- "vars_dark": Object with HSL color values for dark mode + +All color values must be in the shadcn HSL format: "{hue} {saturation}% {lightness}%" where hue is 0-360, saturation is 0-100%, lightness is 0-100%. + +Required CSS variables in both vars_light and vars_dark: +- background, foreground +- card, card-foreground +- popover, popover-foreground +- primary, primary-foreground +- secondary, secondary-foreground +- muted, muted-foreground +- accent, accent-foreground +- destructive, destructive-foreground +- border, input, ring +- radius (e.g. "0.5rem")`; + +export default function AiThemeGenerator({ + onGenerated, +}: AiThemeGeneratorProps) { const [prompt, setPrompt] = useState(''); const [loading, setLoading] = useState(false); const handleGenerate = async () => { if (!prompt) { -return; -} + return; + } setLoading(true); try { - const response = await fetch(generate().url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content || '', + const response = await puter.ai.chat( + [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: prompt }, + ], + { + model: import.meta.env.VITE_PUTER_MODEL || 'gpt-4o-mini', }, - body: JSON.stringify({ prompt }), - }); + ); + + let content = ''; + if (typeof response === 'string') { + content = response; + } else if (response?.message?.content) { + content = response.message.content; + } else { + throw new Error('Invalid response from AI'); + } + + // Clean up code blocks if any + content = content.replace(/```json\n?/, '').replace(/```\n?/, ''); + + const data = JSON.parse(content); - if (!response.ok) { - throw new Error('Failed to generate theme'); + // Add basic kebab name if missing + if (!data.name && data.title) { + data.name = data.title.toLowerCase().replace(/[^a-z0-9]+/g, '-'); } - const data = await response.json(); onGenerated(data); toast.success('Theme generated successfully!'); } catch (error) { diff --git a/tests/Feature/AiDescriptionTest.php b/tests/Feature/AiDescriptionTest.php index a708e09..4341fac 100644 --- a/tests/Feature/AiDescriptionTest.php +++ b/tests/Feature/AiDescriptionTest.php @@ -15,7 +15,7 @@ class AiDescriptionTest extends TestCase public function test_it_generates_description_and_tags_on_theme_creation() { Http::fake([ - 'https://openrouter.ai/*' => Http::response([ + 'https://api.puter.com/puterai/openai/v1/chat/completions' => Http::response([ 'choices' => [ [ 'message' => [ @@ -38,8 +38,8 @@ public function test_it_generates_description_and_tags_on_theme_creation() ], 200), ]); - config(['services.openrouter.key' => 'test-key']); - config(['services.openrouter.model' => 'test-model']); + config(['services.puter.key' => 'test-key']); + config(['services.puter.model' => 'test-model']); $user = User::factory()->create(); diff --git a/tests/Feature/ThemesControllerTest.php b/tests/Feature/ThemesControllerTest.php index c6a6693..5c6b957 100644 --- a/tests/Feature/ThemesControllerTest.php +++ b/tests/Feature/ThemesControllerTest.php @@ -43,7 +43,7 @@ test('user can generate theme via AI', function () { Http::fake([ - 'https://openrouter.ai/*' => Http::response([ + 'https://api.puter.com/puterai/openai/v1/chat/completions' => Http::response([ 'choices' => [ [ 'message' => [ @@ -103,8 +103,8 @@ ], 200), ]); - config(['services.openrouter.key' => 'test-key']); - config(['services.openrouter.model' => 'test-model']); + config(['services.puter.key' => 'test-key']); + config(['services.puter.model' => 'test-model']); $user = User::factory()->create(); @@ -124,10 +124,10 @@ test('ai generate returns 500 when openrouter api fails', function () { Http::fake([ - 'https://openrouter.ai/*' => Http::response([], 500), + 'https://api.puter.com/puterai/openai/v1/chat/completions' => Http::response([], 500), ]); - config(['services.openrouter.key' => 'test-key']); + config(['services.puter.key' => 'test-key']); $user = User::factory()->create(); @@ -140,7 +140,7 @@ }); test('ai generate returns 500 when no api key is configured', function () { - config(['services.openrouter.key' => null]); + config(['services.puter.key' => null]); $user = User::factory()->create();