mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
web search feature added
This commit is contained in:
parent
dac37b4344
commit
78dd4c677f
@ -43,6 +43,7 @@ import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
|
||||
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { StickToBottom, useStickToBottomContext } from '~/lib/hooks';
|
||||
import { WebSearch } from './WebSearch.client';
|
||||
|
||||
const TEXTAREA_MIN_HEIGHT = 76;
|
||||
|
||||
@ -82,6 +83,7 @@ interface BaseChatProps {
|
||||
clearDeployAlert?: () => void;
|
||||
data?: JSONValue[] | undefined;
|
||||
actionRunner?: ActionRunner;
|
||||
onWebSearchResult?: (result: string) => void;
|
||||
}
|
||||
|
||||
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
@ -120,6 +122,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
clearSupabaseAlert,
|
||||
data,
|
||||
actionRunner,
|
||||
onWebSearchResult,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@ -590,6 +593,14 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
<IconButton title="Upload file" className="transition-all" onClick={() => handleFileUpload()}>
|
||||
<div className="i-ph:paperclip text-xl"></div>
|
||||
</IconButton>
|
||||
<WebSearch
|
||||
onSearchResult={(result) => {
|
||||
if (onWebSearchResult) {
|
||||
onWebSearchResult(result);
|
||||
}
|
||||
}}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<IconButton
|
||||
title="Enhance prompt"
|
||||
disabled={input.length === 0 || enhancingPrompt}
|
||||
|
@ -504,6 +504,11 @@ export const ChatImpl = memo(
|
||||
Cookies.set('selectedProvider', newProvider.name, { expires: 30 });
|
||||
};
|
||||
|
||||
const handleWebSearchResult = (result: string) => {
|
||||
setInput(result);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseChat
|
||||
ref={animationScope}
|
||||
@ -564,6 +569,7 @@ export const ChatImpl = memo(
|
||||
deployAlert={deployAlert}
|
||||
clearDeployAlert={() => workbenchStore.clearDeployAlert()}
|
||||
data={chatData}
|
||||
onWebSearchResult={handleWebSearchResult}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
113
app/components/chat/WebSearch.client.tsx
Normal file
113
app/components/chat/WebSearch.client.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface WebSearchProps {
|
||||
onSearchResult: (result: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface WebSearchResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
title: string;
|
||||
description: string;
|
||||
mainContent: string;
|
||||
codeBlocks: string[];
|
||||
relevantLinks: Array<{
|
||||
url: string;
|
||||
text: string;
|
||||
}>;
|
||||
sourceUrl: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const WebSearch = ({ onSearchResult, disabled = false }: WebSearchProps) => {
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const formatSearchResult = (data: WebSearchResponse['data']) => {
|
||||
if (!data) return '';
|
||||
|
||||
let result = `# Web Search Results from ${data.sourceUrl}\n\n`;
|
||||
result += `## ${data.title}\n\n`;
|
||||
|
||||
if (data.description) {
|
||||
result += `**Description:** ${data.description}\n\n`;
|
||||
}
|
||||
|
||||
result += `**Main Content:**\n${data.mainContent}\n\n`;
|
||||
|
||||
if (data.codeBlocks.length > 0) {
|
||||
result += `## Code Examples\n\n`;
|
||||
data.codeBlocks.forEach((block, index) => {
|
||||
result += `\`\`\`\n${block}\n\`\`\`\n\n`;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.relevantLinks.length > 0) {
|
||||
result += `## Relevant Links\n\n`;
|
||||
data.relevantLinks.forEach(link => {
|
||||
result += `- [${link.text}](${link.url})\n`;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleWebSearch = async () => {
|
||||
if (disabled) return;
|
||||
|
||||
try {
|
||||
setIsSearching(true);
|
||||
const url = window.prompt('Enter URL to search:');
|
||||
|
||||
if (!url) {
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('url', url);
|
||||
|
||||
const response = await fetch('/api-web-search', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json() as WebSearchResponse;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to perform web search');
|
||||
}
|
||||
|
||||
if (!data.data) {
|
||||
throw new Error('No data received from web search');
|
||||
}
|
||||
|
||||
const formattedResult = formatSearchResult(data.data);
|
||||
onSearchResult(formattedResult);
|
||||
toast.success('Web search completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Web search error:', error);
|
||||
toast.error('Failed to perform web search: ' + (error instanceof Error ? error.message : 'Unknown error'));
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
title="Web Search"
|
||||
disabled={disabled || isSearching}
|
||||
onClick={handleWebSearch}
|
||||
className="transition-all"
|
||||
>
|
||||
{isSearching ? (
|
||||
<div className="i-svg-spinners:90-ring-with-bg text-bolt-elements-loader-progress text-xl animate-spin"></div>
|
||||
) : (
|
||||
<div className="i-ph:globe text-xl"></div>
|
||||
)}
|
||||
</IconButton>
|
||||
);
|
||||
};
|
BIN
app/components/header/logo_roar.png
Normal file
BIN
app/components/header/logo_roar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 74 KiB |
97
app/routes/api-web-search.ts
Normal file
97
app/routes/api-web-search.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { json } from '@remix-run/node';
|
||||
import type { ActionFunctionArgs } from '@remix-run/node';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const url = formData.get('url') as string;
|
||||
|
||||
if (!url) {
|
||||
return json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Add proper headers to handle CORS and content type
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType?.includes('text/html')) {
|
||||
throw new Error('URL must point to an HTML page');
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Extract title
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
const title = titleMatch ? titleMatch[1].trim() : 'No title found';
|
||||
|
||||
// Extract meta description
|
||||
const descriptionMatch = html.match(/<meta[^>]*name="description"[^>]*content="([^"]*)"[^>]*>/i);
|
||||
const description = descriptionMatch ? descriptionMatch[1].trim() : '';
|
||||
|
||||
// Extract main content
|
||||
const mainContent = html
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
// Extract code blocks
|
||||
const codeBlocks = html.match(/<pre[^>]*>[\s\S]*?<\/pre>|<code[^>]*>[\s\S]*?<\/code>/gi) || [];
|
||||
const formattedCodeBlocks = codeBlocks.map(block => {
|
||||
return block
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.trim();
|
||||
});
|
||||
|
||||
// Extract links
|
||||
const links = html.match(/<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/gi) || [];
|
||||
const formattedLinks = links.map(link => {
|
||||
const hrefMatch = link.match(/href="([^"]*)"/i);
|
||||
const textMatch = link.match(/>([^<]*)</i);
|
||||
return {
|
||||
url: hrefMatch ? hrefMatch[1] : '',
|
||||
text: textMatch ? textMatch[1].trim() : ''
|
||||
};
|
||||
});
|
||||
|
||||
// Structure the content for code generation
|
||||
const structuredContent = {
|
||||
title,
|
||||
description,
|
||||
mainContent: mainContent.slice(0, 1000) + '...',
|
||||
codeBlocks: formattedCodeBlocks,
|
||||
relevantLinks: formattedLinks.filter(link =>
|
||||
link.url &&
|
||||
!link.url.startsWith('#') &&
|
||||
!link.url.startsWith('javascript:') &&
|
||||
link.text.trim()
|
||||
),
|
||||
sourceUrl: url
|
||||
};
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: structuredContent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Web search error:', error);
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : 'Unknown error occurred' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
48
app/routes/api/web-search.ts
Normal file
48
app/routes/api/web-search.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { json } from '@remix-run/node';
|
||||
import type { ActionFunctionArgs } from '@remix-run/node';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const url = formData.get('url') as string;
|
||||
|
||||
if (!url) {
|
||||
return json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch URL: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// Basic HTML parsing to extract title and content
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
const title = titleMatch ? titleMatch[1].trim() : 'No title found';
|
||||
|
||||
// Extract content by removing script and style tags, then getting text content
|
||||
const content = html
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 1000) + '...'; // Limit content length
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
data: {
|
||||
title,
|
||||
content,
|
||||
url
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Web search error:', error);
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : 'Unknown error occurred' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
22643
package-lock.json
generated
Normal file
22643
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -151,7 +151,6 @@
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-debounce": "^10.0.4",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -160,6 +159,7 @@
|
||||
"@electron/notarize": "^2.5.0",
|
||||
"@iconify-json/ph": "^1.2.1",
|
||||
"@iconify/types": "^2.0.0",
|
||||
"@mendable/firecrawl-js": "^1.24.0",
|
||||
"@remix-run/dev": "^2.15.2",
|
||||
"@remix-run/serve": "^2.15.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
@ -199,7 +199,8 @@
|
||||
"vite-plugin-optimize-css-modules": "^1.1.0",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^2.1.7",
|
||||
"wrangler": "^4.5.1"
|
||||
"wrangler": "^4.5.1",
|
||||
"zod": "^3.24.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"@typescript-eslint/utils": "^8.0.0-alpha.30"
|
||||
|
Loading…
Reference in New Issue
Block a user