Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/studio/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ VITE_SUPABASE_ANON_KEY=
VITE_MIXPANEL_TOKEN=

# Add your keys here to use Anthropic directly
VITE_ANTHROPIC_API_KEY=
VITE_ANTHROPIC_API_KEY=
# Add your Firecrawl API key here
VITE_FIRECRAWL_API_KEY=
1 change: 1 addition & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource-variable/inter": "^5.1.0",
"@mendable/firecrawl-js": "^1.24.0",
"@onlook/foundation": "*",
"@onlook/supabase": "*",
"@onlook/ui": "*",
Expand Down
10 changes: 9 additions & 1 deletion apps/studio/src/lib/projects/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MainChannels } from '@onlook/models/constants';
import { makeAutoObservable } from 'mobx';
import { ProjectTabs, type ProjectsManager } from '.';
import { invokeMainChannel, sendAnalytics } from '../utils';
import type { CrawledContent } from '@/lib/services/crawler';

export enum CreateState {
PROMPT = 'prompting',
Expand Down Expand Up @@ -89,9 +90,15 @@ export class CreateManager {
}
}

async sendPrompt(prompt: string, images: ImageMessageContext[], blank: boolean = false) {
async sendPrompt(
prompt: string,
images: ImageMessageContext[],
crawledContent?: CrawledContent,
blank: boolean = false,
) {
sendAnalytics('prompt create project', {
prompt,
crawledContent,
blank,
});

Expand All @@ -104,6 +111,7 @@ export class CreateManager {
} else {
result = await invokeMainChannel(MainChannels.CREATE_NEW_PROJECT_PROMPT, {
prompt,
crawledContent,
images,
});
}
Expand Down
105 changes: 105 additions & 0 deletions apps/studio/src/lib/services/crawler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import FirecrawlApp from '@mendable/firecrawl-js';

export interface CrawlOptions {
limit?: number;
scrapeOptions?: {
formats?: (
| 'markdown'
| 'html'
| 'rawHtml'
| 'content'
| 'links'
| 'screenshot'
| 'screenshot@fullPage'
| 'extract'
| 'json'
| 'changeTracking'
)[];
};
}

export interface CrawlerResponse {
success: boolean;
error?: string;
data: Array<{
html?: string;
markdown?: string;
}>;
}

export interface CrawledContent {
markdown?: string;
html?: string;
}

export function validateCrawlerResponse(response: unknown): response is CrawlerResponse {
if (!response || typeof response !== 'object') {
return false;
}

if (!('success' in response) || typeof response.success !== 'boolean') {
return false;
}

if (!('data' in response) || !Array.isArray(response.data)) {
return false;
}

if (response.data.length === 0) {
return false;
}

const firstItem = response.data[0];
return (
typeof firstItem === 'object' &&
firstItem !== null &&
('html' in firstItem || 'markdown' in firstItem) &&
(firstItem.html === undefined || typeof firstItem.html === 'string') &&
(firstItem.markdown === undefined || typeof firstItem.markdown === 'string')
);
}

export class CrawlerService {
private static instance: CrawlerService;

private app: FirecrawlApp;

private constructor() {
const apiKey = import.meta.env.VITE_FIRECRAWL_API_KEY;
if (!apiKey) {
throw new Error(
'VITE_FIRECRAWL_API_KEY is not defined. Please provide a valid API key.',
);
}
this.app = new FirecrawlApp({ apiKey });
}

static getInstance(): CrawlerService {
if (!this.instance) {
this.instance = new CrawlerService();
}
return this.instance;
}

async crawlUrl(
url: string,
options: CrawlOptions = {
limit: 100,
scrapeOptions: {
formats: ['markdown', 'html'],
},
},
) {
try {
const response = await this.app.crawlUrl(url, options);

if (!response.success) {
throw new Error(`Failed to crawl: ${response.error}`);
}
return response;
} catch (error) {
console.error('Error during crawling:', error);
throw error;
}
}
}
10 changes: 10 additions & 0 deletions apps/studio/src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@
"fileReference": "File Reference",
"submit": "Start building your site"
},
"crawl": {
"title": "Duplicate a website",
"description": "Paste a link to a website that you want to duplicate",
"input": {
"placeholder": "Paste a link to a website",
"ariaLabel": "URL input for web page crawling",
"crawling": "Getting Data",
"submit": "Get Data"
}
},
"blankStart": "Start from a blank page"
}
},
Expand Down
140 changes: 138 additions & 2 deletions apps/studio/src/routes/projects/PromptCreation/PromptingCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { useEffect, useRef, useState } from 'react';
import useResizeObserver from 'use-resize-observer';
import { DraftImagePill } from '../../editor/EditPanel/ChatTab/ContextPills/DraftingImagePill';
import { useTranslation } from 'react-i18next';
import { CrawlerService, validateCrawlerResponse } from '@/lib/services/crawler';
import type { CrawledContent } from '@/lib/services/crawler';
import { toast } from '@onlook/ui/use-toast';

export const PromptingCard = () => {
const projectsManager = useProjectsManager();
Expand All @@ -28,6 +31,9 @@ export const PromptingCard = () => {
const [isComposing, setIsComposing] = useState(false);
const imageRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const [urlInput, setUrlInput] = useState('');
const [isCrawling, setIsCrawling] = useState(false);
const [crawledValue, setCrawledValue] = useState<CrawledContent>({ markdown: '', html: '' });

useEffect(() => {
const handleEscapeKey = (e: KeyboardEvent) => {
Expand All @@ -45,11 +51,12 @@ export const PromptingCard = () => {
console.warn('Input is too short');
return;
}
projectsManager.create.sendPrompt(inputValue, selectedImages, false);
projectsManager.create.sendPrompt(inputValue, selectedImages, crawledValue, false);
setCrawledValue({ markdown: '', html: '' });
};

const handleBlankSubmit = async () => {
projectsManager.create.sendPrompt('', [], true);
projectsManager.create.sendPrompt('', [], { html: '', markdown: '' }, true);
};

const handleDragOver = (e: React.DragEvent) => {
Expand Down Expand Up @@ -179,6 +186,72 @@ export const PromptingCard = () => {
}
};

const handleCrawlSubmit = async () => {
const trimmedUrlInput = urlInput.trim();
if (!trimmedUrlInput) {
toast({
title: 'URL Required',
description: 'Please enter a URL before submitting.',
variant: 'destructive',
});
return;
}

try {
const url = new URL(trimmedUrlInput);
if (!['http:', 'https:'].includes(url.protocol)) {
console.warn('URL must start with http or https');
toast({
title: 'Invalid URL',
description: 'Please enter a URL that starts with http or https.',
variant: 'destructive',
});
return;
}
} catch (error) {
console.warn('Invalid URL:', trimmedUrlInput);
toast({
title: 'Invalid URL',
description: 'Please enter a valid URL format.',
variant: 'destructive',
});
return;
}

setIsCrawling(true);

try {
const crawler = CrawlerService.getInstance();
const response = await crawler.crawlUrl(trimmedUrlInput);

if (!validateCrawlerResponse(response)) {
throw new Error('Invalid response format from crawler');
}

const responseData = response.data;
setCrawledValue({
html: responseData[0]?.html || '',
markdown: responseData[0]?.markdown || '',
});

toast({
title: 'URL Crawled',
description: `Data for ${trimmedUrlInput} has been crawled successfully.`,
});

setUrlInput('');
} catch (error) {
console.error('Failed to crawl URL:', error);
toast({
title: 'Failed to Crawl URL',
description: error instanceof Error ? error.message : 'An unknown error occurred',
variant: 'destructive',
});
} finally {
setIsCrawling(false);
}
};

return (
<MotionConfig transition={{ duration: 0.5, type: 'spring', bounce: 0 }}>
<div className="flex flex-col gap-4 mb-12">
Expand Down Expand Up @@ -382,6 +455,69 @@ export const PromptingCard = () => {
</CardContent>
</motion.div>
</MotionCard>
<MotionCard
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="w-[600px] backdrop-blur-md bg-background/30 overflow-hidden"
>
<CardHeader>
<motion.h2
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="text-2xl text-foreground-primary"
>
{t('projects.prompt.crawl.title')}
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-sm text-foreground-secondary"
>
{t('projects.prompt.crawl.description')}
</motion.p>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-2">
<input
type="url"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
aria-label={t('projects.prompt.crawl.input.ariaLabel')}
placeholder={t('projects.prompt.crawl.input.placeholder')}
className={cn(
'flex-1 h-9 px-3 rounded-md',
'bg-background-secondary/80 backdrop-blur-sm',
'border border-border',
'text-sm text-foreground-primary',
'placeholder:text-foreground-secondary',
'focus:outline-none focus:ring-2 focus:ring-ring',
)}
/>
<Button
variant="secondary"
className="gap-2"
disabled={!urlInput.trim() || isCrawling}
onClick={handleCrawlSubmit}
>
{isCrawling ? (
<>
<Icons.Circle className="w-4 h-4 animate-spin" />{' '}
{t('projects.prompt.crawl.input.crawling')}
</>
) : (
<>
<Icons.ArrowRight className="w-4 h-4" />{' '}
{t('projects.prompt.crawl.input.submit')}
</>
)}
</Button>
</div>
</div>
</CardContent>
</MotionCard>
<Button
variant="outline"
className="w-fit mx-auto bg-background-secondary/90 text-sm border text-foreground-secondary"
Expand Down
10 changes: 10 additions & 0 deletions apps/web/client/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@
"fileReference": "File Reference",
"submit": "Start building your site"
},
"crawl": {
"title": "Duplicate a website",
"description": "Paste a link to a website that you want to duplicate",
"input": {
"placeholder": "Paste a link to a website",
"ariaLabel": "URL input for web page crawling",
"crawling": "Getting Data",
"submit": "Get Data"
}
},
"blankStart": "Start from a blank page"
}
},
Expand Down