mirror of
https://github.com/supabase/supabase.git
synced 2026-10-18 07:39:07 +03:00
Add option to add web content
This commit is contained in:
1 parent
83cf1426f7
commit
db5e6fc50f
6 files changed
+243
-9
No files matched your search
@@ -3,7 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { last } from 'lodash'
|
||||
import { ExternalLink, FileText, MessageCircleMore, Plus, WandSparkles } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { Message as MessageType } from 'ai/react'
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
Input_Shadcn_,
|
||||
SheetHeader,
|
||||
SheetSection,
|
||||
Tooltip_Shadcn_,
|
||||
@@ -98,6 +99,8 @@ export const AIAssistant = ({
|
||||
const [assistantError, setAssistantError] = useState<string>()
|
||||
const [lastSentMessage, setLastSentMessage] = useState<MessageType>()
|
||||
const [isConfirmOptInModalOpen, setIsConfirmOptInModalOpen] = useState(false)
|
||||
const [webUrl, setWebUrl] = useState('')
|
||||
const [webContent, setWebContent] = useState('')
|
||||
|
||||
const docsUrl = retrieveDocsUrl(selectedDatabaseEntity as SupportedAssistantEntities)
|
||||
const entityContext = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === selectedDatabaseEntity)
|
||||
@@ -134,7 +137,7 @@ export const AIAssistant = ({
|
||||
} = useChat({
|
||||
id,
|
||||
api: `${BASE_PATH}/api/ai/sql/generate-v2`,
|
||||
body: { entityDefinitions, context: selectedDatabaseEntity },
|
||||
body: { entityDefinitions, context: selectedDatabaseEntity, webContent },
|
||||
onError: (error) => setAssistantError(JSON.parse(error.message).error),
|
||||
})
|
||||
|
||||
@@ -275,6 +278,35 @@ export const AIAssistant = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messages.length])
|
||||
|
||||
async function handleWebSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!webUrl) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BASE_PATH}/api/ai/web-url/get-url-content`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url: webUrl }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch web content')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('Fetched web content:', data.content)
|
||||
|
||||
setWebContent(data.content)
|
||||
} catch (error) {
|
||||
console.error('Error fetching web content:', error)
|
||||
toast.error('Failed to fetch web content. Please try again.')
|
||||
}
|
||||
|
||||
// setWebUrl('')
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
@@ -508,6 +540,27 @@ export const AIAssistant = ({
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="gap-x-2">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<p>Web</p>
|
||||
<p className="text-foreground-lighter">
|
||||
Add a link to a website to help the assistant answer your question
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="p-0 w-72">
|
||||
<div className="flex flex-col gap-y-1 p-4 ">
|
||||
<form onSubmit={handleWebSubmit}>
|
||||
<Input_Shadcn_
|
||||
placeholder="https://example.com/docs"
|
||||
value={webUrl}
|
||||
onChange={(e) => setWebUrl(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
@@ -574,6 +627,25 @@ export const AIAssistant = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{webUrl.length > 0 && (
|
||||
<ContextBadge
|
||||
label="Web"
|
||||
value={webContent.length > 0 ? webContent.slice(0, 20) + '...' : webUrl}
|
||||
onRemove={() => setWebUrl('')}
|
||||
tooltip={
|
||||
webContent.length > 20 ? (
|
||||
<>
|
||||
<p className="text-foreground-light">
|
||||
{webContent.length} site selected:
|
||||
</p>
|
||||
<ul className="list-disc pl-4">
|
||||
<li>{webUrl}</li>
|
||||
</ul>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AssistantChatForm
|
||||
|
||||
@@ -8,7 +8,6 @@ export const config = {
|
||||
runtime: 'edge',
|
||||
/* To avoid OpenAI errors, restrict to the Vercel Edge Function regions that
|
||||
overlap with the OpenAI API regions.
|
||||
|
||||
Reference for Vercel regions: https://vercel.com/docs/edge-network/regions#region-list
|
||||
Reference for OpenAI regions: https://help.openai.com/en/articles/5347006-openai-api-supported-countries-and-territories
|
||||
*/
|
||||
@@ -72,12 +71,20 @@ async function handlePost(request: NextRequest) {
|
||||
existingSql?: string
|
||||
entityDefinitions: string[]
|
||||
context?: SupportedAssistantEntities
|
||||
webContent?: string
|
||||
}>)
|
||||
|
||||
const { messages, existingSql, entityDefinitions, context } = body
|
||||
const { messages, existingSql, entityDefinitions, context, webContent } = body
|
||||
|
||||
try {
|
||||
const stream = await chatSql(openai, messages, existingSql, entityDefinitions, context)
|
||||
const stream = await chatSql(
|
||||
openai,
|
||||
messages,
|
||||
existingSql,
|
||||
entityDefinitions,
|
||||
context,
|
||||
webContent
|
||||
)
|
||||
return new StreamingTextResponse(stream)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
import * as cheerio from 'cheerio'
|
||||
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: { message: 'Method Not Allowed' } })
|
||||
}
|
||||
|
||||
const { url } = req.body
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: { message: 'URL is required' } })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const html = await response.text()
|
||||
const $ = cheerio.load(html)
|
||||
|
||||
// Extract title
|
||||
const title = $('title').text().trim()
|
||||
|
||||
// Extract meta description
|
||||
const description = $('meta[name="description"]').attr('content')?.trim() || ''
|
||||
|
||||
// Extract main content
|
||||
const mainContent = extractMainContent($)
|
||||
|
||||
return res.status(200).json({
|
||||
title,
|
||||
description,
|
||||
content: mainContent,
|
||||
url,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching web content:', error)
|
||||
return res.status(500).json({ error: { message: 'Failed to fetch web content' } })
|
||||
}
|
||||
}
|
||||
|
||||
function extractMainContent($: cheerio.CheerioAPI): string {
|
||||
// Remove script tags, style tags, and comments
|
||||
$('script, style, comment').remove()
|
||||
|
||||
// Common selectors for main content
|
||||
const contentSelectors = [
|
||||
'article',
|
||||
'[role="main"]',
|
||||
'.main-content',
|
||||
'#main-content',
|
||||
'.post-content',
|
||||
'.entry-content',
|
||||
'.content',
|
||||
'#content',
|
||||
'main',
|
||||
]
|
||||
|
||||
let mainContent = ''
|
||||
|
||||
// Try to find content using common selectors
|
||||
for (const selector of contentSelectors) {
|
||||
const element = $(selector)
|
||||
if (element.length > 0) {
|
||||
mainContent = element.text().trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no content found, use a density-based approach
|
||||
if (!mainContent) {
|
||||
mainContent = findContentByDensity($)
|
||||
}
|
||||
|
||||
// Fallback to body content if still empty
|
||||
if (!mainContent) {
|
||||
mainContent = $('body').text().trim()
|
||||
}
|
||||
|
||||
// Limit content length and clean it up
|
||||
return cleanAndLimitContent(mainContent)
|
||||
}
|
||||
|
||||
function findContentByDensity($: cheerio.CheerioAPI): string {
|
||||
let bestElement: cheerio.Element | null = null
|
||||
let maxScore = 0
|
||||
|
||||
$('p')
|
||||
.parent()
|
||||
.each((_, element) => {
|
||||
const $element = $(element)
|
||||
const text = $element.text()
|
||||
const linkDensity = $element.find('a').text().length / text.length || 0
|
||||
const score = text.length * (1 - linkDensity)
|
||||
|
||||
if (score > maxScore) {
|
||||
maxScore = score
|
||||
bestElement = element
|
||||
}
|
||||
})
|
||||
|
||||
return bestElement ? $(bestElement).text().trim() : ''
|
||||
}
|
||||
|
||||
function cleanAndLimitContent(content: string): string {
|
||||
// Remove extra whitespace and newlines
|
||||
content = content.replace(/\s+/g, ' ').trim()
|
||||
|
||||
// Limit to around 1000 words
|
||||
const words = content.split(' ')
|
||||
if (words.length > 1000) {
|
||||
content = words.slice(0, 1000).join(' ') + '...'
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
Generated
+11
@@ -14,6 +14,9 @@
|
||||
"playwright-tests",
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@types/cheerio": "^0.22.35"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.0",
|
||||
"prettier": "^4.0.0-alpha.8",
|
||||
@@ -16452,6 +16455,14 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.2.tgz",
|
||||
"integrity": "sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg=="
|
||||
},
|
||||
"node_modules/@types/cheerio": {
|
||||
"version": "0.22.35",
|
||||
"resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.35.tgz",
|
||||
"integrity": "sha512-yD57BchKRvTV+JD53UZ6PD8KWY5g5rvvMLRnZR3EQBCZXiDT/HR+pKpMzFGlWNhFrXlo7VPZXtKvIEwZkAWOIA==",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/classnames": {
|
||||
"version": "2.3.1",
|
||||
"dev": true,
|
||||
|
||||
+18
-3
@@ -5,7 +5,12 @@
|
||||
"author": "Supabase, Inc.",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"workspaces": ["apps/*", "tests", "playwright-tests", "packages/*"],
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"tests",
|
||||
"playwright-tests",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"build:studio": "turbo run build --filter=studio",
|
||||
@@ -53,6 +58,16 @@
|
||||
"npm": "^10.0.0",
|
||||
"node": "^20.0.0"
|
||||
},
|
||||
"keywords": ["postgres", "firebase", "storage", "functions", "database", "auth"],
|
||||
"packageManager": "npm@10.7.0"
|
||||
"keywords": [
|
||||
"postgres",
|
||||
"firebase",
|
||||
"storage",
|
||||
"functions",
|
||||
"database",
|
||||
"auth"
|
||||
],
|
||||
"packageManager": "npm@10.7.0",
|
||||
"dependencies": {
|
||||
"@types/cheerio": "^0.22.35"
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@ export async function chatSql(
|
||||
messages: Message[],
|
||||
existingSql?: string,
|
||||
entityDefinitions?: string[],
|
||||
context?: 'rls-policies' | 'functions'
|
||||
context?: 'rls-policies' | 'functions',
|
||||
webContent?: string
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
const initMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
|
||||
{
|
||||
@@ -82,6 +83,13 @@ export async function chatSql(
|
||||
})
|
||||
}
|
||||
|
||||
if (webContent) {
|
||||
initMessages.push({
|
||||
role: 'user',
|
||||
content: `Here is some additional web content for context: ${webContent}`,
|
||||
})
|
||||
}
|
||||
|
||||
if (messages) {
|
||||
initMessages.push(...messages)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user