Unverified 提交 de52b9da authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Integrating web crawling in the custom theme generator (#2347)

<!-- CURSOR_SUMMARY --> > [!NOTE] > Introduces URL-based theme generation alongside image uploads, enabling prompts derived from live websites. > > - New `Website URL` input source in `AIGeneratorTab` with toggle, validation, crawl status indicators, and adjusted generate button/empty states > - Adds `useGenerateThemeFromUrl` hook and integrates it with existing generation flow and loading state > - Extends IPC types with `ThemeInputSource`, `CrawlStatus`, and `GenerateThemeFromUrlParams`; updates `templateContracts` with `generate-theme-from-url` > - Implements `generate-theme-from-url` handler: validates inputs, calls Dyad Engine `/tools/web-crawl`, selects web-crawl-specific system prompts (inspired/high-fidelity), streams model output, and returns prompt with robust error handling > - Preserves and refines image-based generation; resets state on dialog close and adds small UI polish (icons, counters, disabled states) > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit b5b1aebb277ce421953a06b148bd342fded2a64f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Add URL-based theme generation with web crawling to the custom theme generator, alongside image uploads. Users can paste a website URL to extract a design system and generate a prompt with clear generation status, with added security and reliability improvements. - **New Features** - Source toggle: switch between “Upload Images” and “Website URL,” with state reset on dialog close. - URL input flow: validate URL and update button states/text; show generating state during processing. - New hook and types: useGenerateThemeFromUrl, ThemeInputSource, CrawlStatus. - IPC + contract: generate-theme-from-url with GenerateThemeFromUrlParams and result typing. - Backend handler: validates Dyad Pro + API key, URL, keywords; crawls via Dyad Engine (/tools/web-crawl); uses screenshot + markdown with truncation; distinct meta prompts for inspired vs high-fidelity; model mapping (Gemini 3 Pro, Claude Opus 4.5, GPT-5.2); streams prompt; returns clear errors; test mode stub output. - **Bug Fixes** - Security: restrict to HTTP/HTTPS URLs; block internal/private hosts (SSRF); sanitize crawled markdown and user keywords; Zod-validate crawl response. - Reliability: 120s crawl timeout with AbortController; clearer errors; UUID request IDs. - UI: add “generating” state; fix effect deps to avoid races; correct button disabled states. - Code health: shared model map at module scope (DRY). <sup>Written for commit b8aef2d51b1c5b7de5266f5afdfeb3174a64285b. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2347"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude <noreply@anthropic.com> Co-authored-by: 's avatarWill Chen <willchen90@gmail.com>
上级 9108f67c
...@@ -271,3 +271,68 @@ test("themes management - AI generator flow", async ({ po }) => { ...@@ -271,3 +271,68 @@ test("themes management - AI generator flow", async ({ po }) => {
await expect(po.page.getByText("AI Generated Theme")).toBeVisible(); await expect(po.page.getByText("AI Generated Theme")).toBeVisible();
await expect(po.page.getByText("Created via AI generator")).toBeVisible(); await expect(po.page.getByText("Created via AI generator")).toBeVisible();
}); });
test("themes management - AI generator from website URL", async ({ po }) => {
await po.setUpDyadPro();
// Navigate to Themes page via Library sidebar
await po.goToLibraryTab();
await po.page.getByRole("link", { name: "Themes" }).click();
await expect(po.page.getByRole("heading", { name: "Themes" })).toBeVisible();
// Click New Theme button
await po.page.getByRole("button", { name: "New Theme" }).click();
// Wait for dialog to open
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
// Verify AI-Powered Generator tab is active by default
const aiTab = po.page.getByRole("tab", { name: "AI-Powered Generator" });
await expect(aiTab).toHaveAttribute("data-state", "active");
// Switch to Website URL input source
await po.page.getByRole("button", { name: "Website URL" }).click();
// Verify URL input is visible
const urlInput = po.page.getByLabel("Website URL");
await expect(urlInput).toBeVisible();
// Verify Generate button is disabled before entering URL
const generateButton = po.page.getByRole("button", {
name: "Generate Theme Prompt",
});
await expect(generateButton).toBeDisabled();
// Fill in theme details
await po.page.getByLabel("Theme Name").fill("Website Theme");
await po.page
.getByLabel("Description (optional)")
.fill("Generated from website");
// Enter a website URL
await urlInput.fill("https://example.com");
// Verify Generate button is now enabled
await expect(generateButton).toBeEnabled();
// Click Generate to get mock theme prompt (test mode returns mock response)
await generateButton.click();
// Wait for generation to complete - the generated prompt textarea should appear
await expect(po.page.locator("#ai-prompt")).toBeVisible({ timeout: 10000 });
// Verify the mock theme content is displayed (URL-specific mock)
await expect(po.page.getByText("Test Mode Theme (from URL)")).toBeVisible();
// Save the theme
await po.page.getByRole("button", { name: "Save Theme" }).click();
// Verify dialog closes and theme card appears
await expect(po.page.getByRole("dialog")).not.toBeVisible();
const themeCard = po.page.getByTestId("theme-card");
await expect(themeCard).toBeVisible();
await expect(themeCard.getByText("Website Theme")).toBeVisible();
await expect(themeCard.getByText("Generated from website")).toBeVisible();
});
...@@ -3,14 +3,21 @@ import { Button } from "@/components/ui/button"; ...@@ -3,14 +3,21 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Loader2, Upload, X, Sparkles, Lock } from "lucide-react"; import { Loader2, Upload, X, Sparkles, Lock, Link } from "lucide-react";
import { useGenerateThemePrompt } from "@/hooks/useCustomThemes"; import {
useGenerateThemePrompt,
useGenerateThemeFromUrl,
} from "@/hooks/useCustomThemes";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
import { toast } from "sonner"; import { toast } from "sonner";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo"; import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
import { AiAccessBanner } from "./ProBanner"; import { AiAccessBanner } from "./ProBanner";
import type { ThemeGenerationMode, ThemeGenerationModel } from "@/ipc/types"; import type {
ThemeGenerationMode,
ThemeGenerationModel,
ThemeInputSource,
} from "@/ipc/types";
// Image upload constants // Image upload constants
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB per image (raw file size) const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB per image (raw file size)
...@@ -60,8 +67,14 @@ export function AIGeneratorTab({ ...@@ -60,8 +67,14 @@ export function AIGeneratorTab({
// Track if dialog is open to prevent orphaned uploads from adding images after close // Track if dialog is open to prevent orphaned uploads from adding images after close
const isDialogOpenRef = useRef(isDialogOpen); const isDialogOpenRef = useRef(isDialogOpen);
// URL-based generation state
const [inputSource, setInputSource] = useState<ThemeInputSource>("images");
const [websiteUrl, setWebsiteUrl] = useState("");
const generatePromptMutation = useGenerateThemePrompt(); const generatePromptMutation = useGenerateThemePrompt();
const isGenerating = generatePromptMutation.isPending; const generateFromUrlMutation = useGenerateThemeFromUrl();
const isGenerating =
generatePromptMutation.isPending || generateFromUrlMutation.isPending;
const { userBudget } = useUserBudgetInfo(); const { userBudget } = useUserBudgetInfo();
// Cleanup function to revoke blob URLs and delete temp files // Cleanup function to revoke blob URLs and delete temp files
...@@ -92,16 +105,28 @@ export function AIGeneratorTab({ ...@@ -92,16 +105,28 @@ export function AIGeneratorTab({
isDialogOpenRef.current = isDialogOpen; isDialogOpenRef.current = isDialogOpen;
}, [isDialogOpen]); }, [isDialogOpen]);
// Cleanup images when dialog closes // Keep a ref to current images for cleanup without causing effect re-runs
const aiImagesRef = useRef<ThemeImage[]>([]);
useEffect(() => {
aiImagesRef.current = aiImages;
}, [aiImages]);
// Cleanup images and reset state when dialog closes
useEffect(() => { useEffect(() => {
if (!isDialogOpen && aiImages.length > 0) { if (!isDialogOpen) {
cleanupImages(aiImages); // Use ref to get current images to avoid dependency on aiImages
const imagesToCleanup = aiImagesRef.current;
if (imagesToCleanup.length > 0) {
cleanupImages(imagesToCleanup);
setAiImages([]); setAiImages([]);
}
setAiKeywords(""); setAiKeywords("");
setAiGenerationMode("inspired"); setAiGenerationMode("inspired");
setAiSelectedModel(DEFAULT_THEME_GENERATION_MODEL); setAiSelectedModel(DEFAULT_THEME_GENERATION_MODEL);
setInputSource("images");
setWebsiteUrl("");
} }
}, [isDialogOpen, aiImages, cleanupImages]); }, [isDialogOpen, cleanupImages]);
const handleImageUpload = useCallback( const handleImageUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => { async (e: React.ChangeEvent<HTMLInputElement>) => {
...@@ -219,6 +244,8 @@ export function AIGeneratorTab({ ...@@ -219,6 +244,8 @@ export function AIGeneratorTab({
); );
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
if (inputSource === "images") {
// Image-based generation
if (aiImages.length === 0) { if (aiImages.length === 0) {
showError("Please upload at least one image"); showError("Please upload at least one image");
return; return;
...@@ -238,12 +265,38 @@ export function AIGeneratorTab({ ...@@ -238,12 +265,38 @@ export function AIGeneratorTab({
`Failed to generate theme: ${error instanceof Error ? error.message : "Unknown error"}`, `Failed to generate theme: ${error instanceof Error ? error.message : "Unknown error"}`,
); );
} }
} else {
// URL-based generation
if (!websiteUrl.trim()) {
showError("Please enter a website URL");
return;
}
try {
const result = await generateFromUrlMutation.mutateAsync({
url: websiteUrl,
keywords: aiKeywords,
generationMode: aiGenerationMode,
model: aiSelectedModel,
});
setAiGeneratedPrompt(result.prompt);
toast.success("Theme prompt generated from website");
} catch (error) {
showError(
`Failed to generate theme: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
}, [ }, [
inputSource,
aiImages, aiImages,
websiteUrl,
aiKeywords, aiKeywords,
aiGenerationMode, aiGenerationMode,
aiSelectedModel, aiSelectedModel,
generatePromptMutation, generatePromptMutation,
generateFromUrlMutation,
setAiGeneratedPrompt, setAiGeneratedPrompt,
]); ]);
...@@ -291,7 +344,45 @@ export function AIGeneratorTab({ ...@@ -291,7 +344,45 @@ export function AIGeneratorTab({
/> />
</div> </div>
{/* Image Upload Section */} {/* Input Source Toggle */}
<div className="space-y-3">
<Label>Reference Source</Label>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => setInputSource("images")}
className={`flex flex-col items-center rounded-lg border p-3 text-center transition-colors ${
inputSource === "images"
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<Upload className="h-5 w-5 mb-1" />
<span className="font-medium text-sm">Upload Images</span>
<span className="text-xs text-muted-foreground mt-1">
Use screenshots from your device
</span>
</button>
<button
type="button"
onClick={() => setInputSource("url")}
className={`flex flex-col items-center rounded-lg border p-3 text-center transition-colors ${
inputSource === "url"
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<Link className="h-5 w-5 mb-1" />
<span className="font-medium text-sm">Website URL</span>
<span className="text-xs text-muted-foreground mt-1">
Extract design from a live website
</span>
</button>
</div>
</div>
{/* Image Upload Section - only shown when inputSource is "images" */}
{inputSource === "images" && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Reference Images</Label> <Label>Reference Images</Label>
<div <div
...@@ -349,6 +440,25 @@ export function AIGeneratorTab({ ...@@ -349,6 +440,25 @@ export function AIGeneratorTab({
</div> </div>
)} )}
</div> </div>
)}
{/* URL Input Section - only shown when inputSource is "url" */}
{inputSource === "url" && (
<div className="space-y-2">
<Label htmlFor="website-url">Website URL</Label>
<Input
id="website-url"
type="url"
placeholder="https://example.com"
value={websiteUrl}
onChange={(e) => setWebsiteUrl(e.target.value)}
disabled={isGenerating}
/>
<p className="text-xs text-muted-foreground">
Enter a website URL to extract its design system
</p>
</div>
)}
{/* Keywords Input */} {/* Keywords Input */}
<div className="space-y-2"> <div className="space-y-2">
...@@ -452,14 +562,20 @@ export function AIGeneratorTab({ ...@@ -452,14 +562,20 @@ export function AIGeneratorTab({
{/* Generate Button */} {/* Generate Button */}
<Button <Button
onClick={handleGenerate} onClick={handleGenerate}
disabled={isGenerating || aiImages.length === 0} disabled={
isGenerating ||
(inputSource === "images" && aiImages.length === 0) ||
(inputSource === "url" && !websiteUrl.trim())
}
variant="secondary" variant="secondary"
className="w-full" className="w-full"
> >
{isGenerating ? ( {isGenerating ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating prompt... {inputSource === "url"
? "Generating from website..."
: "Generating prompt..."}
</> </>
) : ( ) : (
<> <>
...@@ -481,9 +597,11 @@ export function AIGeneratorTab({ ...@@ -481,9 +597,11 @@ export function AIGeneratorTab({
placeholder="Generated prompt will appear here..." placeholder="Generated prompt will appear here..."
/> />
) : ( ) : (
<div className="min-h-[100px] border rounded-md p-4 flex items-center justify-center text-muted-foreground text-sm"> <div className="min-h-[100px] border rounded-md p-4 flex items-center justify-center text-muted-foreground text-sm text-center">
No prompt generated yet. Upload images and click "Generate" to No prompt generated yet.{" "}
create a theme prompt. {inputSource === "images"
? 'Upload images and click "Generate" to create a theme prompt.'
: 'Enter a website URL and click "Generate" to extract a theme.'}
</div> </div>
)} )}
</div> </div>
......
...@@ -6,6 +6,7 @@ import type { ...@@ -6,6 +6,7 @@ import type {
UpdateCustomThemeParams, UpdateCustomThemeParams,
GenerateThemePromptParams, GenerateThemePromptParams,
GenerateThemePromptResult, GenerateThemePromptResult,
GenerateThemeFromUrlParams,
} from "@/ipc/types"; } from "@/ipc/types";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
...@@ -92,3 +93,13 @@ export function useGenerateThemePrompt() { ...@@ -92,3 +93,13 @@ export function useGenerateThemePrompt() {
}, },
}); });
} }
export function useGenerateThemeFromUrl() {
return useMutation({
mutationFn: async (
params: GenerateThemeFromUrlParams,
): Promise<GenerateThemePromptResult> => {
return ipc.template.generateThemeFromUrl(params);
},
});
}
...@@ -243,8 +243,11 @@ export type { ...@@ -243,8 +243,11 @@ export type {
DeleteCustomThemeParams, DeleteCustomThemeParams,
ThemeGenerationMode, ThemeGenerationMode,
ThemeGenerationModel, ThemeGenerationModel,
ThemeInputSource,
CrawlStatus,
GenerateThemePromptParams, GenerateThemePromptParams,
GenerateThemePromptResult, GenerateThemePromptResult,
GenerateThemeFromUrlParams,
SaveThemeImageParams, SaveThemeImageParams,
SaveThemeImageResult, SaveThemeImageResult,
CleanupThemeImagesParams, CleanupThemeImagesParams,
......
...@@ -100,6 +100,14 @@ export const ThemeGenerationModelSchema = z.enum([ ...@@ -100,6 +100,14 @@ export const ThemeGenerationModelSchema = z.enum([
]); ]);
export type ThemeGenerationModel = z.infer<typeof ThemeGenerationModelSchema>; export type ThemeGenerationModel = z.infer<typeof ThemeGenerationModelSchema>;
// Theme input source (images or URL)
export const ThemeInputSourceSchema = z.enum(["images", "url"]);
export type ThemeInputSource = z.infer<typeof ThemeInputSourceSchema>;
// Crawl status for UI feedback
export const CrawlStatusSchema = z.enum(["crawling", "complete", "error"]);
export type CrawlStatus = z.infer<typeof CrawlStatusSchema>;
export const GenerateThemePromptParamsSchema = z.object({ export const GenerateThemePromptParamsSchema = z.object({
imagePaths: z.array(z.string()), imagePaths: z.array(z.string()),
keywords: z.string(), keywords: z.string(),
...@@ -119,6 +127,31 @@ export type GenerateThemePromptResult = z.infer< ...@@ -119,6 +127,31 @@ export type GenerateThemePromptResult = z.infer<
typeof GenerateThemePromptResultSchema typeof GenerateThemePromptResultSchema
>; >;
// URL-based theme generation params
export const GenerateThemeFromUrlParamsSchema = z.object({
url: z
.string()
.url()
.refine(
(url) => {
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
},
{ message: "Only HTTP and HTTPS URLs are supported" },
),
keywords: z.string(),
generationMode: ThemeGenerationModeSchema,
model: ThemeGenerationModelSchema,
});
export type GenerateThemeFromUrlParams = z.infer<
typeof GenerateThemeFromUrlParamsSchema
>;
export const SaveThemeImageParamsSchema = z.object({ export const SaveThemeImageParamsSchema = z.object({
data: z.string(), data: z.string(),
filename: z.string(), filename: z.string(),
...@@ -201,6 +234,12 @@ export const templateContracts = { ...@@ -201,6 +234,12 @@ export const templateContracts = {
output: GenerateThemePromptResultSchema, output: GenerateThemePromptResultSchema,
}), }),
generateThemeFromUrl: defineContract({
channel: "generate-theme-from-url",
input: GenerateThemeFromUrlParamsSchema,
output: GenerateThemePromptResultSchema,
}),
saveThemeImage: defineContract({ saveThemeImage: defineContract({
channel: "save-theme-image", channel: "save-theme-image",
input: SaveThemeImageParamsSchema, input: SaveThemeImageParamsSchema,
......
...@@ -9,7 +9,7 @@ const webCrawlSchema = z.object({ ...@@ -9,7 +9,7 @@ const webCrawlSchema = z.object({
url: z.string().describe("URL to crawl"), url: z.string().describe("URL to crawl"),
}); });
const webCrawlResponseSchema = z.object({ export const webCrawlResponseSchema = z.object({
rootUrl: z.string(), rootUrl: z.string(),
html: z.string().optional(), html: z.string().optional(),
markdown: z.string().optional(), markdown: z.string().optional(),
......
...@@ -12,6 +12,7 @@ import { streamText, TextPart, ImagePart } from "ai"; ...@@ -12,6 +12,7 @@ import { streamText, TextPart, ImagePart } from "ai";
import { readSettings } from "../../../../main/settings"; import { readSettings } from "../../../../main/settings";
import { IS_TEST_BUILD } from "@/ipc/utils/test_utils"; import { IS_TEST_BUILD } from "@/ipc/utils/test_utils";
import { getModelClient } from "../../../../ipc/utils/get_model_client"; import { getModelClient } from "../../../../ipc/utils/get_model_client";
import { v4 as uuidv4 } from "uuid";
import type { import type {
SetAppThemeParams, SetAppThemeParams,
GetAppThemeParams, GetAppThemeParams,
...@@ -21,14 +22,55 @@ import type { ...@@ -21,14 +22,55 @@ import type {
DeleteCustomThemeParams, DeleteCustomThemeParams,
GenerateThemePromptParams, GenerateThemePromptParams,
GenerateThemePromptResult, GenerateThemePromptResult,
GenerateThemeFromUrlParams,
SaveThemeImageParams, SaveThemeImageParams,
SaveThemeImageResult, SaveThemeImageResult,
CleanupThemeImagesParams, CleanupThemeImagesParams,
} from "@/ipc/types"; } from "@/ipc/types";
import { webCrawlResponseSchema } from "./local_agent/tools/web_crawl";
const logger = log.scope("themes_handlers"); const logger = log.scope("themes_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
// Shared model map for theme generation (used by both image and URL handlers)
const THEME_GENERATION_MODEL_MAP: Record<
string,
{ provider: string; name: string }
> = {
"gemini-3-pro": { provider: "google", name: "gemini-3-pro-preview" },
"claude-opus-4.5": {
provider: "anthropic",
name: "claude-opus-4-5",
},
"gpt-5.2": { provider: "openai", name: "gpt-5.2" },
};
// Timeout for web crawl requests (120 seconds)
const WEB_CRAWL_TIMEOUT_MS = 120_000;
/**
* Sanitizes external content before including it in LLM prompts.
* Escapes markdown code block delimiters to prevent prompt injection.
*/
function sanitizeForPrompt(content: string): string {
// Escape backtick sequences that could break out of code blocks
// Replace ``` with escaped version to prevent code block injection
return content.replace(/`{3,}/g, (match) => "\\`".repeat(match.length));
}
/**
* Sanitizes user-provided keywords for use in prompts.
* Limits length and removes potentially dangerous patterns.
*/
function sanitizeKeywords(keywords: string): string {
// Trim and limit length
let sanitized = keywords.trim().slice(0, 500);
// Remove potential prompt injection patterns
sanitized = sanitized.replace(/<\/?[^>]+(>|$)/g, ""); // Strip HTML-like tags
sanitized = sanitized.replace(/`{3,}/g, ""); // Remove code block markers
return sanitized;
}
// Directory for storing temporary theme images // Directory for storing temporary theme images
const THEME_IMAGES_TEMP_DIR = path.join(os.tmpdir(), "dyad-theme-images"); const THEME_IMAGES_TEMP_DIR = path.join(os.tmpdir(), "dyad-theme-images");
...@@ -142,6 +184,98 @@ REQUIRED STRUCTURE ...@@ -142,6 +184,98 @@ REQUIRED STRUCTURE
- Self-Check - Self-Check
`; `;
// Web crawl "inspired" mode prompt - separate from image-based prompt
const WEB_CRAWL_THEME_GENERATION_META_PROMPT = `PURPOSE
- Generate a strict SYSTEM PROMPT that extracts a reusable UI DESIGN SYSTEM from a crawled website.
- You are provided with a screenshot image and markdown representation of a live website.
- This is a visual ruleset, not a website blueprint.
- Extract constraints, scales, and principles from the visual appearance.
- You are NOT recreating, cloning, or reverse-engineering the specific website.
- The resulting system must be applicable to unrelated products without visual resemblance.
INPUTS
- Screenshot image of the website (PRIMARY reference for visual style)
- Markdown text content (for understanding structure and hierarchy)
- Optional keywords for style guidance
SCOPE & LIMITATIONS (MANDATORY)
- Do NOT reproduce:
- Page layouts
- Component hierarchies
- Spatial arrangements
- Relative positioning between elements
- Information architecture
- Do NOT describe the original interface or reference the crawled URL.
- The output must remain abstract, systemic, and transferable.
FIXED TECH STACK
- Assume React + Tailwind CSS + shadcn/ui.
- Hard Rules:
- Never ship default shadcn styles
- No inline styles
- No arbitrary values outside defined scales
- All styling must be token-driven
OUTPUT RULES
- Wrap the entire output in <theme></theme> tags.
- Output exactly ONE SYSTEM PROMPT that:
- Names any inspiration strictly as a stylistic reference, not a target
- Defines enforceable rules, never descriptions
- Uses imperative language only ("must", "never", "always")
- Never mentions the screenshot, URL, or crawled content
- Produces a system that cannot recreate the original UI even if followed precisely
REQUIRED STRUCTURE
- Visual Objective (abstract, non-descriptive)
- Layout & Spacing Rules (scales only, no patterns)
- Typography System (roles, hierarchy, constraints)
- Color & Surfaces (tokens, elevation logic)
- Components & Shape Language (geometry, affordances — no layouts)
- Motion & Interaction (timing, intent, limits)
- Forbidden Patterns (explicit anti-cloning rules)
- Self-Check (verifies abstraction & non-replication)
`;
// Web crawl "high-fidelity" mode prompt - separate from image-based prompt
const WEB_CRAWL_HIGH_FIDELITY_META_PROMPT = `PURPOSE
- Generate a strict SYSTEM PROMPT that allows an AI to recreate a UI visual system from a crawled website.
- You are provided with a screenshot image and markdown representation of a live website.
- This is a visual subsystem. Do not define roles or personas.
- Extract rules, not descriptions. Use the screenshot as primary visual reference.
INPUTS
- Screenshot image of the website (PRIMARY reference - use for visual accuracy)
- Markdown text content (supplementary - for text hierarchy)
- Optional reference name for the design inspiration
- Screenshot always takes priority over markdown.
FIXED TECH STACK
- Assume React + Tailwind CSS + shadcn/ui.
- Rules:
- Never ship default shadcn styles
- No inline styles
- No arbitrary values outside defined scales
OUTPUT RULES
- Wrap the entire output in <theme></theme> tags.
- Output one SYSTEM PROMPT that:
- Explicitly names the inspiration as a guiding reference
- Uses hard, enforceable rules only
- Is technical and unambiguous
- Never mentions the screenshot or crawled URL
- Avoids vague language ("might", "appears", etc.)
REQUIRED STRUCTURE
- Visual Objective
- Layout & Spacing Rules
- Typography System
- Color & Surfaces
- Components & Shape Language
- Motion & Interaction
- Forbidden Patterns
- Self-Check
`;
export function registerThemesHandlers() { export function registerThemesHandlers() {
// Get built-in themes // Get built-in themes
handle("get-themes", async (): Promise<Theme[]> => { handle("get-themes", async (): Promise<Theme[]> => {
...@@ -471,16 +605,7 @@ Modern dark theme with purple accents for testing. ...@@ -471,16 +605,7 @@ Modern dark theme with purple accents for testing.
} }
// Validate and map model selection // Validate and map model selection
const modelMap: Record<string, { provider: string; name: string }> = { const selectedModel = THEME_GENERATION_MODEL_MAP[params.model];
"gemini-3-pro": { provider: "google", name: "gemini-3-pro-preview" },
"claude-opus-4.5": {
provider: "anthropic",
name: "claude-opus-4-5",
},
"gpt-5.2": { provider: "openai", name: "gpt-5.2" },
};
const selectedModel = modelMap[params.model];
if (!selectedModel) { if (!selectedModel) {
throw new Error("Invalid model selection"); throw new Error("Invalid model selection");
} }
...@@ -494,8 +619,8 @@ Modern dark theme with purple accents for testing. ...@@ -494,8 +619,8 @@ Modern dark theme with purple accents for testing.
? HIGH_FIDELITY_META_PROMPT ? HIGH_FIDELITY_META_PROMPT
: THEME_GENERATION_META_PROMPT; : THEME_GENERATION_META_PROMPT;
// Build the user input prompt // Build the user input prompt (sanitize user-provided keywords)
const keywordsPart = params.keywords.trim() || "N/A"; const keywordsPart = sanitizeKeywords(params.keywords) || "N/A";
const imagesPart = const imagesPart =
params.imagePaths.length > 0 params.imagePaths.length > 0
? `${params.imagePaths.length} image(s) attached` ? `${params.imagePaths.length} image(s) attached`
...@@ -559,4 +684,215 @@ images: ${imagesPart}`; ...@@ -559,4 +684,215 @@ images: ${imagesPart}`;
} }
}, },
); );
// Generate theme prompt from website URL via web crawl
handle(
"generate-theme-from-url",
async (
_,
params: GenerateThemeFromUrlParams,
): Promise<GenerateThemePromptResult> => {
const settings = readSettings();
// Return mock response in test mode
if (IS_TEST_BUILD) {
return {
prompt: `<theme>
# Test Mode Theme (from URL)
## Visual Objective
Modern theme extracted from website for testing.
</theme>`,
};
}
if (!settings.enableDyadPro) {
throw new Error(
"Dyad Pro is required for AI theme generation. Please enable Dyad Pro in Settings.",
);
}
// Validate URL format and protocol
let parsedUrl: URL;
try {
parsedUrl = new URL(params.url);
} catch {
throw new Error("Invalid URL format. Please enter a valid URL.");
}
// Only allow HTTP/HTTPS protocols (security: prevent file://, javascript://, etc.)
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
throw new Error(
"Invalid URL protocol. Only HTTP and HTTPS URLs are supported.",
);
}
// SSRF protection: block internal/private network addresses
const hostname = parsedUrl.hostname.toLowerCase();
const blockedPatterns = [
/^localhost$/i,
/^127\.\d+\.\d+\.\d+$/,
/^10\.\d+\.\d+\.\d+$/,
/^192\.168\.\d+\.\d+$/,
/^172\.(1[6-9]|2[0-9]|3[0-1])\.\d+\.\d+$/,
/^169\.254\.\d+\.\d+$/,
/^::1$/,
/\.local$/i,
];
if (blockedPatterns.some((p) => p.test(hostname))) {
throw new Error("Cannot crawl internal network addresses.");
}
// Validate keywords length
if (params.keywords.length > 500) {
throw new Error("Keywords must be less than 500 characters");
}
// Validate generation mode
if (!["inspired", "high-fidelity"].includes(params.generationMode)) {
throw new Error("Invalid generation mode");
}
// Validate and map model selection
const selectedModel = THEME_GENERATION_MODEL_MAP[params.model];
if (!selectedModel) {
throw new Error("Invalid model selection");
}
// Get API key for Dyad Engine
const apiKey = settings.providerSettings?.auto?.apiKey?.value;
if (!apiKey) {
throw new Error("Dyad Pro API key is required");
}
// Crawl the website
logger.log(`Crawling website for theme: ${params.url}`);
const DYAD_ENGINE_URL =
process.env.DYAD_ENGINE_URL ?? "https://engine.dyad.sh/v1";
// Create AbortController for timeout
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
WEB_CRAWL_TIMEOUT_MS,
);
let crawlResponse: Response;
try {
crawlResponse = await fetch(`${DYAD_ENGINE_URL}/tools/web-crawl`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"X-Dyad-Request-Id": `theme-crawl-${uuidv4()}`,
},
body: JSON.stringify({ url: params.url }),
signal: controller.signal,
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(
"Website crawl timed out. The website may be too slow or unresponsive.",
);
}
throw new Error(
"Failed to connect to crawl service. Please check your internet connection and try again.",
);
} finally {
clearTimeout(timeoutId);
}
if (!crawlResponse.ok) {
const errorText = await crawlResponse.text();
throw new Error(
`Failed to crawl website: ${crawlResponse.status} - ${errorText}`,
);
}
// Validate response with Zod schema
const rawCrawlResult = await crawlResponse.json();
const parseResult = webCrawlResponseSchema.safeParse(rawCrawlResult);
if (!parseResult.success) {
logger.error("Invalid crawl response structure:", parseResult.error);
throw new Error(
"Received invalid response from crawl service. Please try again.",
);
}
const crawlResult = parseResult.data;
if (!crawlResult.screenshot) {
throw new Error(
"Failed to capture website screenshot. Please try a different URL.",
);
}
if (!crawlResult.markdown) {
throw new Error(
"Failed to extract website content. Please try a different URL.",
);
}
logger.log(`Website crawled successfully: ${params.url}`);
// Use the selected model for theme generation
const { modelClient } = await getModelClient(selectedModel, settings);
// Select system prompt based on generation mode
const systemPrompt =
params.generationMode === "high-fidelity"
? WEB_CRAWL_HIGH_FIDELITY_META_PROMPT
: WEB_CRAWL_THEME_GENERATION_META_PROMPT;
// Build the user input prompt (sanitize user-provided keywords)
const keywordsPart = sanitizeKeywords(params.keywords) || "N/A";
const userInput = `inspired by: ${keywordsPart}
source: Live website (screenshot and content provided)`;
// Truncate markdown if too long (consistent with existing web_crawl.ts)
const MAX_MARKDOWN_LENGTH = 16000;
const truncatedMarkdown =
crawlResult.markdown.length > MAX_MARKDOWN_LENGTH
? crawlResult.markdown.slice(0, MAX_MARKDOWN_LENGTH) +
"\n<!-- truncated -->"
: crawlResult.markdown;
// Sanitize crawled content to prevent prompt injection
const sanitizedMarkdown = sanitizeForPrompt(truncatedMarkdown);
// Build content parts
const contentParts: (TextPart | ImagePart)[] = [
{ type: "text", text: userInput },
{
type: "image",
image: crawlResult.screenshot,
mimeType: "image/png",
} as ImagePart,
{
type: "text",
text: `Website content (markdown):\n\`\`\`markdown\n${sanitizedMarkdown}\n\`\`\``,
},
];
try {
const stream = streamText({
model: modelClient.model,
system: systemPrompt,
maxRetries: 1,
messages: [{ role: "user", content: contentParts }],
});
const result = await stream.text;
return { prompt: result };
} catch (error) {
throw new Error(
error instanceof Error
? error.message
: "Failed to generate theme from website. Please try again.",
);
}
},
);
} }
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论