diff --git a/.env.example b/.env.example index 59a6ca02..52264ab1 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,10 @@ NEXTAUTH_SECRET="your-super-secret-key-change-in-production" # DO_SPACES_SECRET_ACCESS_KEY="" # DO_SPACES_CDN_ENDPOINT="" # Optional: for CDN-enabled Spaces -# AI Search (optional - enable aiSearch in prompts.config.ts) +# AI Features (optional - enable aiSearch/aiGeneration in prompts.config.ts) # OPENAI_API_KEY=your_openai_api_key +# OPENAI_BASE_URL=https://api.openai.com/v1 # Optional: custom base URL for OpenAI-compatible APIs +# OPENAI_EMBEDDING_MODEL=text-embedding-3-small # Optional: embedding model for AI search +# OPENAI_GENERATIVE_MODEL=gpt-4o-mini # Optional: generative model for AI generation # GOOGLE_ANALYTICS_ID="G-XXXXXXXXX" \ No newline at end of file diff --git a/messages/ar.json b/messages/ar.json index 08163baf..73e6c618 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -79,7 +79,10 @@ "sqlQuery": "استعلام SQL", "openDataset": "فتح مجموعة البيانات", "runQuery": "تشغيل الاستعلام على HF", - "examples": "استعلامات نموذجية" + "examples": "أمثلة", + "selectExample": "اختر مثالاً...", + "aiGenerate": "توليد AI", + "aiPlaceholder": "صف استعلام SQL الذي تحتاجه..." }, "edit": "تعديل الأمر", "delete": "حذف الأمر", @@ -509,7 +512,8 @@ "search": "بحث", "clear": "مسح", "found": "تم العثور على {count}", - "aiSearch": "بحث الذكاء الاصطناعي" + "aiSearch": "بحث الذكاء الاصطناعي", + "searchTags": "البحث عن العلامات..." }, "user": { "profile": "الملف الشخصي", diff --git a/messages/de.json b/messages/de.json index 196a8e60..4cd6b2d9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -79,7 +79,10 @@ "sqlQuery": "SQL-Abfrage", "openDataset": "Datensatz öffnen", "runQuery": "Abfrage auf HF ausführen", - "examples": "Beispielabfragen" + "examples": "Beispiele", + "selectExample": "Beispiel auswählen...", + "aiGenerate": "KI Generieren", + "aiPlaceholder": "Beschreiben Sie die benötigte SQL-Abfrage..." }, "edit": "Prompt bearbeiten", "delete": "Prompt löschen", @@ -509,7 +512,8 @@ "search": "Suchen", "clear": "Löschen", "found": "{count} gefunden", - "aiSearch": "KI-Suche" + "aiSearch": "KI-Suche", + "searchTags": "Tags suchen..." }, "user": { "profile": "Profil", diff --git a/messages/en.json b/messages/en.json index 8ae45aa7..23d47906 100644 --- a/messages/en.json +++ b/messages/en.json @@ -79,7 +79,10 @@ "sqlQuery": "SQL Query", "openDataset": "Open Dataset", "runQuery": "Run Query on HF", - "examples": "Example Queries" + "examples": "Examples", + "selectExample": "Select an example...", + "aiGenerate": "AI Generate", + "aiPlaceholder": "Describe the SQL query you need..." }, "edit": "Edit Prompt", "delete": "Delete Prompt", @@ -509,7 +512,8 @@ "search": "Search", "clear": "Clear", "found": "{count} found", - "aiSearch": "AI Search" + "aiSearch": "AI Search", + "searchTags": "Search tags..." }, "user": { "profile": "Profile", diff --git a/messages/es.json b/messages/es.json index d3a8a4ea..7f1a3d8a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -79,7 +79,10 @@ "sqlQuery": "Consulta SQL", "openDataset": "Abrir Dataset", "runQuery": "Ejecutar Consulta en HF", - "examples": "Consultas de Ejemplo" + "examples": "Ejemplos", + "selectExample": "Selecciona un ejemplo...", + "aiGenerate": "Generar con IA", + "aiPlaceholder": "Describe la consulta SQL que necesitas..." }, "edit": "Editar Prompt", "delete": "Eliminar Prompt", @@ -509,7 +512,8 @@ "search": "Buscar", "clear": "Limpiar", "found": "{count} encontrados", - "aiSearch": "Búsqueda IA" + "aiSearch": "Búsqueda IA", + "searchTags": "Buscar etiquetas..." }, "user": { "profile": "Perfil", diff --git a/messages/fr.json b/messages/fr.json index 8084755d..3b90570b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -79,7 +79,10 @@ "sqlQuery": "Requête SQL", "openDataset": "Ouvrir le Dataset", "runQuery": "Exécuter la Requête sur HF", - "examples": "Exemples de Requêtes" + "examples": "Exemples", + "selectExample": "Sélectionner un exemple...", + "aiGenerate": "Générer IA", + "aiPlaceholder": "Décrivez la requête SQL dont vous avez besoin..." }, "edit": "Modifier le Prompt", "delete": "Supprimer le Prompt", @@ -509,7 +512,8 @@ "search": "Rechercher", "clear": "Effacer", "found": "{count} trouvés", - "aiSearch": "Recherche IA" + "aiSearch": "Recherche IA", + "searchTags": "Rechercher des tags..." }, "user": { "profile": "Profil", diff --git a/messages/it.json b/messages/it.json index bff8c48e..3d85763f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -79,7 +79,10 @@ "sqlQuery": "Query SQL", "openDataset": "Apri Dataset", "runQuery": "Esegui Query su HF", - "examples": "Query di Esempio" + "examples": "Esempi", + "selectExample": "Seleziona un esempio...", + "aiGenerate": "Genera AI", + "aiPlaceholder": "Descrivi la query SQL di cui hai bisogno..." }, "edit": "Modifica Prompt", "delete": "Elimina Prompt", @@ -509,7 +512,8 @@ "search": "Cerca", "clear": "Cancella", "found": "{count} trovati", - "aiSearch": "Ricerca AI" + "aiSearch": "Ricerca AI", + "searchTags": "Cerca tag..." }, "user": { "profile": "Profilo", diff --git a/messages/ja.json b/messages/ja.json index 1eb71aba..b5721753 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -79,7 +79,10 @@ "sqlQuery": "SQLクエリ", "openDataset": "データセットを開く", "runQuery": "HFでクエリを実行", - "examples": "クエリ例" + "examples": "例", + "selectExample": "例を選択...", + "aiGenerate": "AI生成", + "aiPlaceholder": "必要なSQLクエリを説明してください..." }, "edit": "プロンプトを編集", "delete": "プロンプトを削除", @@ -509,7 +512,8 @@ "search": "検索", "clear": "クリア", "found": "{count}件見つかりました", - "aiSearch": "AI検索" + "aiSearch": "AI検索", + "searchTags": "タグを検索..." }, "user": { "profile": "プロフィール", diff --git a/messages/ko.json b/messages/ko.json index 831031c5..bd6562ef 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -79,7 +79,10 @@ "sqlQuery": "SQL 쿼리", "openDataset": "데이터셋 열기", "runQuery": "HF에서 쿼리 실행", - "examples": "예제 쿼리" + "examples": "예제", + "selectExample": "예제 선택...", + "aiGenerate": "AI 생성", + "aiPlaceholder": "필요한 SQL 쿼리를 설명하세요..." }, "edit": "프롬프트 편집", "delete": "프롬프트 삭제", @@ -509,7 +512,8 @@ "search": "검색", "clear": "지우기", "found": "{count}개 찾음", - "aiSearch": "AI 검색" + "aiSearch": "AI 검색", + "searchTags": "태그 검색..." }, "user": { "profile": "프로필", diff --git a/messages/pt.json b/messages/pt.json index 2dc3300c..d93ccca1 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -79,7 +79,10 @@ "sqlQuery": "Consulta SQL", "openDataset": "Abrir Dataset", "runQuery": "Executar Consulta no HF", - "examples": "Consultas de Exemplo" + "examples": "Exemplos", + "selectExample": "Selecione um exemplo...", + "aiGenerate": "Gerar com IA", + "aiPlaceholder": "Descreva a consulta SQL que você precisa..." }, "edit": "Editar Prompt", "delete": "Excluir Prompt", @@ -509,7 +512,8 @@ "search": "Buscar", "clear": "Limpar", "found": "{count} encontrados", - "aiSearch": "Busca por IA" + "aiSearch": "Busca por IA", + "searchTags": "Pesquisar tags..." }, "user": { "profile": "Perfil", diff --git a/messages/tr.json b/messages/tr.json index 9a665047..a4f34970 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -79,7 +79,10 @@ "sqlQuery": "SQL Sorgusu", "openDataset": "Veri Setini Aç", "runQuery": "HF'de Sorguyu Çalıştır", - "examples": "Örnek Sorgular" + "examples": "Örnekler", + "selectExample": "Bir örnek seçin...", + "aiGenerate": "AI Oluştur", + "aiPlaceholder": "Nasıl bir prompt bulmak istiyorsunuz?" }, "edit": "Prompt Düzenle", "delete": "Prompt Sil", @@ -509,7 +512,8 @@ "search": "Ara", "clear": "Temizle", "found": "{count} bulundu", - "aiSearch": "AI ile Ara" + "aiSearch": "AI ile Ara", + "searchTags": "Etiket ara..." }, "user": { "profile": "Profil", diff --git a/messages/zh.json b/messages/zh.json index 73a277c2..89a015d8 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -79,7 +79,10 @@ "sqlQuery": "SQL 查询", "openDataset": "打开数据集", "runQuery": "在HF上运行查询", - "examples": "示例查询" + "examples": "示例", + "selectExample": "选择一个示例...", + "aiGenerate": "AI生成", + "aiPlaceholder": "描述您需要的SQL查询..." }, "edit": "编辑提示词", "delete": "删除提示词", @@ -509,7 +512,8 @@ "search": "搜索", "clear": "清除", "found": "找到 {count} 个", - "aiSearch": "AI 搜索" + "aiSearch": "AI 搜索", + "searchTags": "搜索标签..." }, "user": { "profile": "个人资料", diff --git a/prompts.config.ts b/prompts.config.ts index 32fa354c..a3e77d09 100644 --- a/prompts.config.ts +++ b/prompts.config.ts @@ -59,7 +59,9 @@ export default defineConfig({ // Enable tags tags: true, // Enable AI-powered semantic search (requires OPENAI_API_KEY) - aiSearch: false, + aiSearch: true, + // Enable AI-powered generation features (requires OPENAI_API_KEY) + aiGeneration: true, }, // Homepage customization diff --git a/src/app/api/generate/sql/route.ts b/src/app/api/generate/sql/route.ts new file mode 100644 index 00000000..89ec0544 --- /dev/null +++ b/src/app/api/generate/sql/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateSQL, isAIGenerationEnabled } from "@/lib/ai/generation"; + +export async function POST(request: NextRequest) { + try { + const enabled = await isAIGenerationEnabled(); + if (!enabled) { + return NextResponse.json( + { error: "AI Generation is not enabled" }, + { status: 400 } + ); + } + + const body = await request.json(); + const { prompt } = body; + + if (!prompt || prompt.trim().length === 0) { + return NextResponse.json( + { error: "Prompt is required" }, + { status: 400 } + ); + } + + const sql = await generateSQL(prompt); + + return NextResponse.json({ sql }); + } catch (error) { + console.error("SQL Generation error:", error); + return NextResponse.json( + { error: "Failed to generate SQL" }, + { status: 500 } + ); + } +} diff --git a/src/app/prompts/loading.tsx b/src/app/prompts/loading.tsx index bf8d607e..ddc575a9 100644 --- a/src/app/prompts/loading.tsx +++ b/src/app/prompts/loading.tsx @@ -1,37 +1,77 @@ import { Skeleton } from "@/components/ui/skeleton"; +function PromptCardSkeleton() { + return ( +
+
+ + +
+ + + +
+ + +
+
+ + +
+
+ ); +} + export default function PromptsLoading() { return (
{/* Header */} -
-
- - +
+
+ + +
+
+ +
-
- {/* Filters */} -
- - - -
- - {/* Prompt Grid */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
- - - -
- - +
+ {/* Sidebar Filters Skeleton */} + + + {/* Main Content - Prompt Grid Skeleton */} +
+
+ {Array.from({ length: 12 }).map((_, i) => ( + + ))} +
+
); diff --git a/src/app/prompts/page.tsx b/src/app/prompts/page.tsx index 65a3df55..7168b40e 100644 --- a/src/app/prompts/page.tsx +++ b/src/app/prompts/page.tsx @@ -5,9 +5,11 @@ import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { InfinitePromptList } from "@/components/prompts/infinite-prompt-list"; import { PromptFilters } from "@/components/prompts/prompt-filters"; +import { FilterProvider } from "@/components/prompts/filter-context"; import { HFDataStudioDropdown } from "@/components/prompts/hf-data-studio-dropdown"; import { db } from "@/lib/db"; import { isAISearchEnabled, semanticSearch } from "@/lib/ai/embeddings"; +import { isAIGenerationEnabled } from "@/lib/ai/generation"; import config from "@/../prompts.config"; export const metadata: Metadata = { @@ -34,6 +36,7 @@ export default async function PromptsPage({ searchParams }: PromptsPageProps) { const perPage = 12; const aiSearchAvailable = await isAISearchEnabled(); + const aiGenerationAvailable = await isAIGenerationEnabled(); const useAISearch = aiSearchAvailable && params.ai === "1" && params.q; let prompts: any[] = []; @@ -177,7 +180,7 @@ export default async function PromptsPage({ searchParams }: PromptsPageProps) {
{!config.homepage?.useCloneBranding && ( - + )}
-
- -
- -
-
+ +
+ +
+ +
+
+
); } diff --git a/src/components/prompts/filter-context.tsx b/src/components/prompts/filter-context.tsx new file mode 100644 index 00000000..c5e41d53 --- /dev/null +++ b/src/components/prompts/filter-context.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { createContext, useContext, useState, ReactNode } from "react"; + +interface FilterContextType { + isFilterPending: boolean; + setFilterPending: (pending: boolean) => void; +} + +const FilterContext = createContext(null); + +export function FilterProvider({ children }: { children: ReactNode }) { + const [isFilterPending, setFilterPending] = useState(false); + + return ( + + {children} + + ); +} + +export function useFilterContext() { + const context = useContext(FilterContext); + if (!context) { + throw new Error("useFilterContext must be used within FilterProvider"); + } + return context; +} diff --git a/src/components/prompts/hf-data-studio-dropdown.tsx b/src/components/prompts/hf-data-studio-dropdown.tsx index 2efa740a..76ab579c 100644 --- a/src/components/prompts/hf-data-studio-dropdown.tsx +++ b/src/components/prompts/hf-data-studio-dropdown.tsx @@ -3,9 +3,11 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; -import { ChevronDown, Play, ExternalLink } from "lucide-react"; +import { ChevronDown, Play, ExternalLink, Sparkles, Loader2 } from "lucide-react"; import Editor from "@monaco-editor/react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Popover, PopoverContent, @@ -24,10 +26,16 @@ const DEFAULT_SQL = SQL_EXAMPLES[0].sql; const HF_DATASET_URL = "https://huggingface.co/datasets/fka/awesome-chatgpt-prompts/viewer"; -export function HFDataStudioDropdown() { +interface HFDataStudioDropdownProps { + aiGenerationEnabled?: boolean; +} + +export function HFDataStudioDropdown({ aiGenerationEnabled = false }: HFDataStudioDropdownProps) { const t = useTranslations("prompts.hfDataStudio"); const { resolvedTheme } = useTheme(); const [sql, setSql] = useState(DEFAULT_SQL); + const [aiPrompt, setAiPrompt] = useState(""); + const [isGenerating, setIsGenerating] = useState(false); const handleOpenDataset = () => { window.open(HF_DATASET_URL, "_blank"); @@ -39,6 +47,28 @@ export function HFDataStudioDropdown() { window.open(url, "_blank"); }; + const handleGenerateSQL = async () => { + if (!aiPrompt.trim() || isGenerating) return; + + setIsGenerating(true); + try { + const response = await fetch("/api/generate/sql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: aiPrompt }), + }); + + if (!response.ok) throw new Error("Failed to generate SQL"); + + const data = await response.json(); + setSql(data.sql); + } catch (error) { + console.error("SQL generation error:", error); + } finally { + setIsGenerating(false); + } + }; + return (
+
+ + )} + + + + + +
+
+ setSql(value || "")} + theme={resolvedTheme === "dark" ? "vs-dark" : "light"} + options={{ + minimap: { enabled: false }, + fontSize: 12, + lineNumbers: "on", + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 2, + wordWrap: "on", + }} + /> +
+
- -
+
diff --git a/src/components/prompts/infinite-prompt-list.tsx b/src/components/prompts/infinite-prompt-list.tsx index a12bdb01..5a203114 100644 --- a/src/components/prompts/infinite-prompt-list.tsx +++ b/src/components/prompts/infinite-prompt-list.tsx @@ -1,8 +1,11 @@ "use client"; import { useEffect, useRef, useState, useCallback } from "react"; +import { useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { Loader2, SearchX } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useFilterContext } from "./filter-context"; import { PromptCard, type PromptCardProps } from "./prompt-card"; interface InfinitePromptListProps { @@ -17,24 +20,49 @@ interface InfinitePromptListProps { }; } +function PromptCardSkeleton() { + return ( +
+
+ + +
+ + + +
+ + +
+
+ + +
+
+ ); +} + export function InfinitePromptList({ initialPrompts, initialTotal, filters }: InfinitePromptListProps) { const t = useTranslations("prompts"); + const searchParams = useSearchParams(); + const { isFilterPending, setFilterPending } = useFilterContext(); const [prompts, setPrompts] = useState(initialPrompts); const [page, setPage] = useState(1); const [isLoading, setIsLoading] = useState(false); const [hasMore, setHasMore] = useState(initialPrompts.length < initialTotal); const loaderRef = useRef(null); - // Reset when filters change + // Reset when new data arrives from server useEffect(() => { setPrompts(initialPrompts); setPage(1); setHasMore(initialPrompts.length < initialTotal); - }, [initialPrompts, initialTotal]); + setFilterPending(false); + }, [initialPrompts, initialTotal, setFilterPending]); const loadMore = useCallback(async () => { if (isLoading || !hasMore) return; @@ -88,6 +116,17 @@ export function InfinitePromptList({ return () => observer.disconnect(); }, [loadMore, hasMore, isLoading]); + // Show skeleton while filtering + if (isFilterPending) { + return ( +
+ {Array.from({ length: 12 }).map((_, i) => ( + + ))} +
+ ); + } + if (prompts.length === 0) { return (
diff --git a/src/components/prompts/prompt-filters.tsx b/src/components/prompts/prompt-filters.tsx index faf956e0..6b135966 100644 --- a/src/components/prompts/prompt-filters.tsx +++ b/src/components/prompts/prompt-filters.tsx @@ -1,7 +1,9 @@ "use client"; +import { useState, useMemo, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; +import { useFilterContext } from "./filter-context"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { @@ -13,7 +15,7 @@ import { } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { X, Sparkles } from "lucide-react"; +import { X, Sparkles, Search } from "lucide-react"; interface PromptFiltersProps { categories: Array<{ @@ -45,6 +47,15 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable const router = useRouter(); const searchParams = useSearchParams(); const t = useTranslations(); + const [tagSearch, setTagSearch] = useState(""); + const { setFilterPending } = useFilterContext(); + const debounceRef = useRef(null); + + const filteredTags = useMemo(() => { + if (!tagSearch.trim()) return tags; + const search = tagSearch.toLowerCase(); + return tags.filter((tag) => tag.name.toLowerCase().includes(search)); + }, [tags, tagSearch]); const updateFilter = (key: string, value: string | null) => { const params = new URLSearchParams(searchParams.toString()); @@ -85,10 +96,17 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable className="h-8 text-sm" defaultValue={currentFilters.q} onChange={(e) => { - const timeout = setTimeout(() => { - updateFilter("q", e.target.value || null); + const value = e.target.value; + // Show loading immediately + setFilterPending(true); + // Clear previous debounce + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + // Debounce the actual navigation + debounceRef.current = setTimeout(() => { + updateFilter("q", value || null); }, 300); - return () => clearTimeout(timeout); }} />
@@ -185,8 +203,17 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable {tags.length > 0 && (
-
- {tags.filter((t) => t.id && t.slug).slice(0, 8).map((tag) => ( +
+ + setTagSearch(e.target.value)} + /> +
+
+ {filteredTags.filter((tag) => tag.id && tag.slug).map((tag) => (
)} diff --git a/src/lib/ai/embeddings.ts b/src/lib/ai/embeddings.ts index 8ce909d7..cefe7571 100644 --- a/src/lib/ai/embeddings.ts +++ b/src/lib/ai/embeddings.ts @@ -11,13 +11,15 @@ function getOpenAIClient(): OpenAI { if (!apiKey) { throw new Error("OPENAI_API_KEY is not set"); } - openai = new OpenAI({ apiKey }); + openai = new OpenAI({ + apiKey, + baseURL: process.env.OPENAI_BASE_URL || undefined, + }); } return openai; } -const EMBEDDING_MODEL = "text-embedding-3-small"; -const EMBEDDING_DIMENSIONS = 1536; +const EMBEDDING_MODEL = process.env.OPENAI_EMBEDDING_MODEL || "text-embedding-3-small"; export async function generateEmbedding(text: string): Promise { const client = getOpenAIClient(); @@ -193,16 +195,20 @@ export async function semanticSearch( }, }); - // Calculate similarity scores - const scoredPrompts = prompts.map((prompt) => { - const embedding = prompt.embedding as number[]; - const similarity = cosineSimilarity(queryEmbedding, embedding); - return { - ...prompt, - similarity, - voteCount: prompt._count.votes, - }; - }); + // Calculate similarity scores and filter by threshold + const SIMILARITY_THRESHOLD = 0.4; // Filter out results below this similarity + + const scoredPrompts = prompts + .map((prompt) => { + const embedding = prompt.embedding as number[]; + const similarity = cosineSimilarity(queryEmbedding, embedding); + return { + ...prompt, + similarity, + voteCount: prompt._count.votes, + }; + }) + .filter((prompt) => prompt.similarity >= SIMILARITY_THRESHOLD); // Sort by similarity and return top results scoredPrompts.sort((a, b) => b.similarity - a.similarity); diff --git a/src/lib/ai/generation.ts b/src/lib/ai/generation.ts new file mode 100644 index 00000000..2262623c --- /dev/null +++ b/src/lib/ai/generation.ts @@ -0,0 +1,67 @@ +import OpenAI from "openai"; +import { getConfig } from "@/lib/config"; + +let openai: OpenAI | null = null; + +function getOpenAIClient(): OpenAI { + if (!openai) { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY is not set"); + } + openai = new OpenAI({ + apiKey, + baseURL: process.env.OPENAI_BASE_URL || undefined, + }); + } + return openai; +} + +const GENERATIVE_MODEL = process.env.OPENAI_GENERATIVE_MODEL || "gpt-4o-mini"; + +export async function isAIGenerationEnabled(): Promise { + const config = await getConfig(); + return !!(config.features.aiGeneration && process.env.OPENAI_API_KEY); +} + +export async function generateSQL(prompt: string): Promise { + const config = await getConfig(); + if (!config.features.aiGeneration) { + throw new Error("AI Generation is not enabled"); + } + + const client = getOpenAIClient(); + + const systemPrompt = `You are an SQL expert. Generate SQL queries for the Hugging Face datasets viewer. +The dataset is "fka/awesome-chatgpt-prompts" with a "train" split. +Available columns: act (prompt title), prompt (prompt content), for_devs (boolean), type (TEXT or STRUCTURED), contributor (author name). + +Rules: +- Always use "train" as the table name +- Return ONLY the SQL query, no explanations +- Keep queries simple and efficient +- Use proper SQL syntax for DuckDB +- The generated SQL strings must be in English since all prompts are in English (e.g. if user searches for "çiçekçi" it's florist.) +- Use multiple matchers like SELECT act, prompt FROM train WHERE LOWER(prompt) LIKE '%travel%' OR LOWER(act) LIKE '%travel%' OR LOWER(act) LIKE '%guide%' LIMIT 20; +- Be generative and creative. e.g. if user wrote "joke", search for similar terms. +- SQL must be formatted into lines.`; + + const response = await client.chat.completions.create({ + model: GENERATIVE_MODEL, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: prompt } + ], + temperature: 0.7, + max_tokens: 500, + }); + + const content = response.choices[0]?.message?.content || ""; + + // Clean up the response - remove markdown code blocks if present + return content + .replace(/^```sql\n?/i, "") + .replace(/^```\n?/i, "") + .replace(/\n?```$/i, "") + .trim(); +} diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index f5abec7f..c9550673 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -44,6 +44,7 @@ export interface FeaturesConfig { categories: boolean; tags: boolean; aiSearch?: boolean; + aiGeneration?: boolean; } export interface Sponsor { @@ -125,6 +126,7 @@ export async function getConfig(): Promise { categories: true, tags: true, aiSearch: false, + aiGeneration: false, }, }; return cachedConfig;