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(),
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论