From 5a7eed664f80075e225b45006cc789f9ffcdd6c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?= Date: Thu, 11 Dec 2025 02:25:24 +0300 Subject: [PATCH] chore(messages): Add AI Search functionality to prompts management pages --- .env.example | 5 +- messages/en.json | 42 +++- messages/tr.json | 44 +++- package-lock.json | 22 ++ package.json | 1 + .../migration.sql | 102 +++++++++ prisma/schema.prisma | 3 + prompts.config.ts | 2 + src/app/admin/page.tsx | 35 ++- src/app/api/admin/embeddings/route.ts | 33 +++ src/app/api/search/ai/route.ts | 39 ++++ src/app/prompts/page.tsx | 180 ++++++++------- src/components/admin/ai-search-settings.tsx | 98 ++++++++ src/components/admin/prompts-management.tsx | 212 +++++++++++++++++ src/components/admin/webhooks-table.tsx | 40 ++-- src/components/prompts/prompt-filters.tsx | 22 +- src/components/prompts/prompt-form.tsx | 82 +++++-- src/components/prompts/variable-toolbar.tsx | 96 ++++++++ src/components/ui/code-editor.tsx | 29 ++- src/lib/ai/embeddings.ts | 214 ++++++++++++++++++ src/lib/auth/index.ts | 17 +- src/lib/config/index.ts | 2 + 22 files changed, 1177 insertions(+), 143 deletions(-) create mode 100644 prisma/migrations/20251210224836_add_embedding_field/migration.sql create mode 100644 src/app/api/admin/embeddings/route.ts create mode 100644 src/app/api/search/ai/route.ts create mode 100644 src/components/admin/ai-search-settings.tsx create mode 100644 src/components/admin/prompts-management.tsx create mode 100644 src/components/prompts/variable-toolbar.tsx create mode 100644 src/lib/ai/embeddings.ts diff --git a/.env.example b/.env.example index 2983139d..534caf68 100644 --- a/.env.example +++ b/.env.example @@ -20,4 +20,7 @@ NEXTAUTH_SECRET="your-super-secret-key-change-in-production" # S3_ENDPOINT="" # For S3-compatible services like MinIO # GITHUB_CLIENT_ID=your_client_id -# GITHUB_CLIENT_SECRET=your_client_secret \ No newline at end of file +# GITHUB_CLIENT_SECRET=your_client_secret + +# AI Search (optional - enable aiSearch in prompts.config.ts) +# OPENAI_API_KEY=your_openai_api_key \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 94b2242b..cd5f2c2b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -130,6 +130,12 @@ "titlePlaceholder": "Enter a title for your prompt", "descriptionPlaceholder": "Optional description of your prompt", "contentPlaceholder": "Enter your prompt content here...", + "insertVariable": "Insert Variable", + "variableName": "Variable Name", + "variableDefault": "Default Value (optional)", + "variableDefaultPlaceholder": "e.g., technology", + "variableHint": "Use $'{'name'}' or $'{'name:default'}' syntax", + "insert": "Insert", "selectCategory": "Select a category", "noCategory": "None", "mediaUrl": "Media URL", @@ -247,7 +253,27 @@ "categories": "Categories", "tags": "Tags", "webhooks": "Webhooks", - "import": "Import Community Prompts" + "prompts": "Prompts" + }, + "prompts": { + "title": "Prompts Management", + "description": "Import prompts from prompts.csv and manage AI embeddings", + "import": "Import CSV", + "importSuccess": "{count} prompts imported", + "allSkipped": "All prompts already exist", + "importResult": "Imported: {imported}, Skipped: {skipped}", + "deleteSuccess": "{count} prompts deleted", + "importConfirmTitle": "Import Prompts?", + "importConfirmDescription": "This will import prompts from prompts.csv. Existing prompts will be skipped.", + "deleteConfirmTitle": "Delete Community Prompts?", + "deleteConfirmDescription": "This will permanently delete all imported prompts and unclaimed contributors.", + "cancel": "Cancel", + "confirm": "Import", + "delete": "Delete", + "generateEmbeddings": "Generate Embeddings", + "pending": "pending", + "embeddingsSuccess": "{count} embeddings generated", + "embeddingsResult": "Generated: {success}, Failed: {failed}" }, "users": { "title": "User Management", @@ -361,7 +387,7 @@ "import": { "title": "Import Community Prompts", "description": "Import prompts from the Awesome ChatGPT Prompts' prompts.csv file", - "fileInfo": "Import from prompts.csv in project root", + "fileInfo": "Import from Awesome ChatGPT Prompts community prompts.csv", "csvFormat": "Format: act, prompt, for_devs, type", "importButton": "Import Community Prompts", "importing": "Importing...", @@ -380,6 +406,15 @@ "deleteConfirmTitle": "Delete Community Prompts?", "deleteConfirmDescription": "This will permanently delete all prompts imported from prompts.csv and unclaimed contributor users. This action cannot be undone.", "deleteSuccess": "{count} community prompts deleted" + }, + "aiSearch": { + "title": "AI Search", + "description": "Generate embeddings for semantic search powered by OpenAI", + "promptsWithoutEmbeddings": "Prompts without embeddings", + "generateButton": "Generate Embeddings", + "generating": "Generating...", + "generateSuccess": "{count} embeddings generated", + "generateResult": "Generated: {success}, Failed: {failed}" } }, "search": { @@ -396,7 +431,8 @@ "mostUpvoted": "Most Upvoted", "search": "Search", "clear": "Clear", - "found": "{count} found" + "found": "{count} found", + "aiSearch": "AI Search" }, "user": { "profile": "Profile", diff --git a/messages/tr.json b/messages/tr.json index 285fb031..2d724cf7 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -130,6 +130,12 @@ "titlePlaceholder": "Promptunuz için bir başlık girin", "descriptionPlaceholder": "İsteğe bağlı açıklama", "contentPlaceholder": "Prompt içeriğinizi buraya girin...", + "insertVariable": "Değişken Ekle", + "variableName": "Değişken Adı", + "variableDefault": "Varsayılan Değer (isteğe bağlı)", + "variableDefaultPlaceholder": "örn., teknoloji", + "variableHint": "$'{'ad'}' veya $'{'ad:varsayılan'}' sözdizimini kullanın", + "insert": "Ekle", "selectCategory": "Kategori seçin", "noCategory": "Yok", "mediaUrl": "Medya URL", @@ -246,8 +252,28 @@ "users": "Kullanıcılar", "categories": "Kategoriler", "tags": "Etiketler", - "webhooks": "Webhook'lar", - "import": "Topluluk Promptlarını İçe Aktar" + "webhooks": "Webhooklar", + "prompts": "Promptlar" + }, + "prompts": { + "title": "Prompt Yönetimi", + "description": "prompts.csv'den prompt içe aktar ve AI gömme vektörlerini yönet", + "import": "CSV İçe Aktar", + "importSuccess": "{count} prompt içe aktarıldı", + "allSkipped": "Tüm promptlar zaten mevcut", + "importResult": "İçe aktarılan: {imported}, Atlanan: {skipped}", + "deleteSuccess": "{count} prompt silindi", + "importConfirmTitle": "Promptlar İçe Aktarılsın mı?", + "importConfirmDescription": "Bu işlem prompts.csv'den promptları içe aktaracak. Mevcut promptlar atlanacak.", + "deleteConfirmTitle": "Topluluk Promptları Silinsin mi?", + "deleteConfirmDescription": "Bu işlem tüm içe aktarılan promptları ve sahipsiz katkıda bulunanları kalıcı olarak silecek.", + "cancel": "İptal", + "confirm": "İçe Aktar", + "delete": "Sil", + "generateEmbeddings": "Gömme Vektörü Oluştur", + "pending": "bekliyor", + "embeddingsSuccess": "{count} gömme vektörü oluşturuldu", + "embeddingsResult": "Oluşturulan: {success}, Başarısız: {failed}" }, "users": { "title": "Kullanıcı Yönetimi", @@ -361,7 +387,7 @@ "import": { "title": "Topluluk Promptlarını İçe Aktar", "description": "Awesome ChatGPT Prompts'un prompts.csv dosyasından promptları içe aktar", - "fileInfo": "Proje kök dizinindeki prompts.csv dosyasından içe aktar", + "fileInfo": "Awesome ChatGPT Prompts topluluk prompts.csv dosyasından içe aktar", "csvFormat": "Format: act, prompt, for_devs, type", "importButton": "Topluluk Promptlarını İçe Aktar", "importing": "İçe aktarılıyor...", @@ -380,6 +406,15 @@ "deleteConfirmTitle": "Topluluk Promptları Silinsin mi?", "deleteConfirmDescription": "Bu işlem prompts.csv'den içe aktarılan tüm promptları ve sahipsiz katkıda bulunan kullanıcıları kalıcı olarak silecek. Bu işlem geri alınamaz.", "deleteSuccess": "{count} topluluk promptu silindi" + }, + "aiSearch": { + "title": "AI Arama", + "description": "OpenAI destekli semantik arama için gömme vektörleri oluştur", + "promptsWithoutEmbeddings": "Gömme vektörü olmayan promptlar", + "generateButton": "Gömme Vektörlerini Oluştur", + "generating": "Oluşturuluyor...", + "generateSuccess": "{count} gömme vektörü oluşturuldu", + "generateResult": "Oluşturulan: {success}, Başarısız: {failed}" } }, "search": { @@ -396,7 +431,8 @@ "mostUpvoted": "En Çok Beğenilen", "search": "Ara", "clear": "Temizle", - "found": "{count} bulundu" + "found": "{count} bulundu", + "aiSearch": "AI ile Ara" }, "user": { "profile": "Profil", diff --git a/package-lock.json b/package-lock.json index 31aff523..a171faec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "next-auth": "^5.0.0-beta.30", "next-intl": "^4.5.8", "next-themes": "^0.4.6", + "openai": "^6.10.0", "react": "19.2.0", "react-dom": "19.2.0", "react-hook-form": "^7.68.0", @@ -8679,6 +8680,27 @@ "devOptional": true, "license": "MIT" }, + "node_modules/openai": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.10.0.tgz", + "integrity": "sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index db16df57..1796ff02 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "next-auth": "^5.0.0-beta.30", "next-intl": "^4.5.8", "next-themes": "^0.4.6", + "openai": "^6.10.0", "react": "19.2.0", "react-dom": "19.2.0", "react-hook-form": "^7.68.0", diff --git a/prisma/migrations/20251210224836_add_embedding_field/migration.sql b/prisma/migrations/20251210224836_add_embedding_field/migration.sql new file mode 100644 index 00000000..2efe1528 --- /dev/null +++ b/prisma/migrations/20251210224836_add_embedding_field/migration.sql @@ -0,0 +1,102 @@ +/* + Warnings: + + - Added the required column `originalContent` to the `change_requests` table without a default value. This is not possible if the table is not empty. + - Added the required column `originalTitle` to the `change_requests` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "StructuredFormat" AS ENUM ('JSON', 'YAML'); + +-- CreateEnum +CREATE TYPE "RequiredMediaType" AS ENUM ('IMAGE', 'VIDEO', 'DOCUMENT'); + +-- CreateEnum +CREATE TYPE "WebhookEvent" AS ENUM ('PROMPT_CREATED', 'PROMPT_UPDATED', 'PROMPT_DELETED'); + +-- AlterEnum +ALTER TYPE "PromptType" ADD VALUE 'STRUCTURED'; + +-- AlterTable +ALTER TABLE "change_requests" ADD COLUMN "originalContent" TEXT NOT NULL, +ADD COLUMN "originalTitle" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "prompts" ADD COLUMN "embedding" JSONB, +ADD COLUMN "requiredMediaCount" INTEGER, +ADD COLUMN "requiredMediaType" "RequiredMediaType", +ADD COLUMN "requiresMediaUpload" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "structuredFormat" "StructuredFormat"; + +-- CreateTable +CREATE TABLE "prompt_votes" ( + "userId" TEXT NOT NULL, + "promptId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "prompt_votes_pkey" PRIMARY KEY ("userId","promptId") +); + +-- CreateTable +CREATE TABLE "pinned_prompts" ( + "userId" TEXT NOT NULL, + "promptId" TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "pinned_prompts_pkey" PRIMARY KEY ("userId","promptId") +); + +-- CreateTable +CREATE TABLE "webhook_configs" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "method" TEXT NOT NULL DEFAULT 'POST', + "headers" JSONB, + "payload" TEXT NOT NULL, + "events" "WebhookEvent"[], + "isEnabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "webhook_configs_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_PromptContributors" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_PromptContributors_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "prompt_votes_userId_idx" ON "prompt_votes"("userId"); + +-- CreateIndex +CREATE INDEX "prompt_votes_promptId_idx" ON "prompt_votes"("promptId"); + +-- CreateIndex +CREATE INDEX "pinned_prompts_userId_idx" ON "pinned_prompts"("userId"); + +-- CreateIndex +CREATE INDEX "_PromptContributors_B_index" ON "_PromptContributors"("B"); + +-- AddForeignKey +ALTER TABLE "prompt_votes" ADD CONSTRAINT "prompt_votes_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "prompt_votes" ADD CONSTRAINT "prompt_votes_promptId_fkey" FOREIGN KEY ("promptId") REFERENCES "prompts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "pinned_prompts" ADD CONSTRAINT "pinned_prompts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "pinned_prompts" ADD CONSTRAINT "pinned_prompts_promptId_fkey" FOREIGN KEY ("promptId") REFERENCES "prompts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_PromptContributors" ADD CONSTRAINT "_PromptContributors_A_fkey" FOREIGN KEY ("A") REFERENCES "prompts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_PromptContributors" ADD CONSTRAINT "_PromptContributors_B_fkey" FOREIGN KEY ("B") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3919d6e8..9c3deb43 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -124,6 +124,9 @@ model Prompt { isPrivate Boolean @default(false) mediaUrl String? // For non-text prompts + // AI Search embedding (stored as JSON array of floats) + embedding Json? // OpenAI embedding vector for semantic search + // Media requirements (user needs to upload media to use this prompt) requiresMediaUpload Boolean @default(false) requiredMediaType RequiredMediaType? diff --git a/prompts.config.ts b/prompts.config.ts index ab8221e2..45d455d7 100644 --- a/prompts.config.ts +++ b/prompts.config.ts @@ -53,5 +53,7 @@ export default defineConfig({ categories: true, // Enable tags tags: true, + // Enable AI-powered semantic search (requires OPENAI_API_KEY) + aiSearch: true, }, }); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index aa184cb1..8de81125 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -3,14 +3,16 @@ import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; +import { Prisma } from "@prisma/client"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Users, FolderTree, Tags, FileText, TrendingUp, Webhook, Upload } from "lucide-react"; +import { Users, FolderTree, Tags, FileText, Webhook } from "lucide-react"; import { UsersTable } from "@/components/admin/users-table"; import { CategoriesTable } from "@/components/admin/categories-table"; import { TagsTable } from "@/components/admin/tags-table"; import { WebhooksTable } from "@/components/admin/webhooks-table"; -import { ImportPrompts } from "@/components/admin/import-prompts"; +import { PromptsManagement } from "@/components/admin/prompts-management"; +import { isAISearchEnabled } from "@/lib/ai/embeddings"; export const metadata: Metadata = { title: "Admin Dashboard", @@ -26,13 +28,25 @@ export default async function AdminPage() { redirect("/"); } - // Fetch stats - const [userCount, promptCount, categoryCount, tagCount] = await Promise.all([ + // Fetch stats and AI search status + const [userCount, promptCount, categoryCount, tagCount, aiSearchEnabled] = await Promise.all([ db.user.count(), db.prompt.count(), db.category.count(), db.tag.count(), + isAISearchEnabled(), ]); + + // Count prompts without embeddings (JSON null check requires separate query) + let promptsWithoutEmbeddings = 0; + if (aiSearchEnabled) { + promptsWithoutEmbeddings = await db.prompt.count({ + where: { + isPrivate: false, + embedding: { equals: Prisma.DbNull }, + }, + }); + } // Fetch data for tables const [users, categories, tags, webhooks] = await Promise.all([ @@ -151,9 +165,9 @@ export default async function AdminPage() { {t("tabs.webhooks")} - - - {t("tabs.import")} + + + {t("tabs.prompts")} @@ -173,8 +187,11 @@ export default async function AdminPage() { - - + + diff --git a/src/app/api/admin/embeddings/route.ts b/src/app/api/admin/embeddings/route.ts new file mode 100644 index 00000000..9b0b684f --- /dev/null +++ b/src/app/api/admin/embeddings/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { generateAllEmbeddings, isAISearchEnabled } from "@/lib/ai/embeddings"; + +export async function POST() { + try { + const session = await auth(); + if (!session?.user || session.user.role !== "ADMIN") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const enabled = await isAISearchEnabled(); + if (!enabled) { + return NextResponse.json( + { error: "AI Search is not enabled or OPENAI_API_KEY is not set" }, + { status: 400 } + ); + } + + const result = await generateAllEmbeddings(); + + return NextResponse.json({ + message: "Embeddings generated", + ...result, + }); + } catch (error) { + console.error("Generate embeddings error:", error); + return NextResponse.json( + { error: "Failed to generate embeddings" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/search/ai/route.ts b/src/app/api/search/ai/route.ts new file mode 100644 index 00000000..56cdb3b1 --- /dev/null +++ b/src/app/api/search/ai/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { semanticSearch, isAISearchEnabled } from "@/lib/ai/embeddings"; + +export async function GET(request: NextRequest) { + try { + const enabled = await isAISearchEnabled(); + if (!enabled) { + return NextResponse.json( + { error: "AI Search is not enabled" }, + { status: 400 } + ); + } + + const searchParams = request.nextUrl.searchParams; + const query = searchParams.get("q"); + const limit = parseInt(searchParams.get("limit") || "20"); + + if (!query || query.trim().length === 0) { + return NextResponse.json( + { error: "Query is required" }, + { status: 400 } + ); + } + + const results = await semanticSearch(query, limit); + + return NextResponse.json({ + results, + query, + count: results.length, + }); + } catch (error) { + console.error("AI Search error:", error); + return NextResponse.json( + { error: "Search failed" }, + { status: 500 } + ); + } +} diff --git a/src/app/prompts/page.tsx b/src/app/prompts/page.tsx index 5bb1d6ed..8f0e1d0c 100644 --- a/src/app/prompts/page.tsx +++ b/src/app/prompts/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { InfinitePromptList } from "@/components/prompts/infinite-prompt-list"; import { PromptFilters } from "@/components/prompts/prompt-filters"; import { db } from "@/lib/db"; +import { isAISearchEnabled, semanticSearch } from "@/lib/ai/embeddings"; export const metadata: Metadata = { title: "Prompts", @@ -20,6 +21,7 @@ interface PromptsPageProps { tag?: string; sort?: string; page?: string; + ai?: string; }>; } @@ -29,90 +31,113 @@ export default async function PromptsPage({ searchParams }: PromptsPageProps) { const params = await searchParams; const perPage = 12; - - // Build where clause based on filters - const where: Record = { - isPrivate: false, - }; - - if (params.q) { - where.OR = [ - { title: { contains: params.q, mode: "insensitive" } }, - { content: { contains: params.q, mode: "insensitive" } }, - { description: { contains: params.q, mode: "insensitive" } }, - ]; + const aiSearchAvailable = await isAISearchEnabled(); + const useAISearch = aiSearchAvailable && params.ai === "1" && params.q; + + let prompts: any[] = []; + let total = 0; + + if (useAISearch && params.q) { + // Use AI semantic search + try { + const aiResults = await semanticSearch(params.q, perPage); + prompts = aiResults.map((p) => ({ + ...p, + contributorCount: 0, + })); + total = aiResults.length; + } catch { + // Fallback to regular search on error + } } - if (params.type) { - where.type = params.type; - } - - if (params.category) { - where.categoryId = params.category; - } - - if (params.tag) { - where.tags = { - some: { - tag: { - slug: params.tag, - }, - }, + // Regular search if AI search not used or failed + if (!useAISearch || prompts.length === 0) { + // Build where clause based on filters + const where: Record = { + isPrivate: false, }; - } - - // Build order by clause - const isUpvoteSort = params.sort === "upvotes"; - let orderBy: any = { createdAt: "desc" }; - if (params.sort === "oldest") { - orderBy = { createdAt: "asc" }; - } else if (isUpvoteSort) { - // Sort by vote count descending - orderBy = { votes: { _count: "desc" } }; - } + + if (params.q) { + where.OR = [ + { title: { contains: params.q, mode: "insensitive" } }, + { content: { contains: params.q, mode: "insensitive" } }, + { description: { contains: params.q, mode: "insensitive" } }, + ]; + } + + if (params.type) { + where.type = params.type; + } + + if (params.category) { + where.categoryId = params.category; + } + + if (params.tag) { + where.tags = { + some: { + tag: { + slug: params.tag, + }, + }, + }; + } + + // Build order by clause + const isUpvoteSort = params.sort === "upvotes"; + let orderBy: any = { createdAt: "desc" }; + if (params.sort === "oldest") { + orderBy = { createdAt: "asc" }; + } else if (isUpvoteSort) { + // Sort by vote count descending + orderBy = { votes: { _count: "desc" } }; + } - // Fetch initial prompts (first page) - const [promptsRaw, total] = await Promise.all([ - db.prompt.findMany({ - where, - orderBy, - skip: 0, - take: perPage, - include: { - author: { - select: { - id: true, - name: true, - username: true, - avatar: true, + // Fetch initial prompts (first page) + const [promptsRaw, totalCount] = await Promise.all([ + db.prompt.findMany({ + where, + orderBy, + skip: 0, + take: perPage, + include: { + author: { + select: { + id: true, + name: true, + username: true, + avatar: true, + }, + }, + category: { + select: { + id: true, + name: true, + slug: true, + }, + }, + tags: { + include: { + tag: true, + }, + }, + _count: { + select: { votes: true, contributors: true }, }, }, - category: { - select: { - id: true, - name: true, - slug: true, - }, - }, - tags: { - include: { - tag: true, - }, - }, - _count: { - select: { votes: true, contributors: true }, - }, - }, - }), - db.prompt.count({ where }), - ]); + }), + db.prompt.count({ where }), + ]); - // Transform to include voteCount and contributorCount - const prompts = promptsRaw.map((p) => ({ - ...p, - voteCount: p._count.votes, - contributorCount: p._count.contributors, - })); + // Transform to include voteCount and contributorCount + prompts = promptsRaw.map((p) => ({ + ...p, + voteCount: p._count.votes, + contributorCount: p._count.contributors, + })); + total = totalCount; + } // Fetch categories for filter (with parent info for nesting) const categories = await db.category.findMany({ @@ -151,6 +176,7 @@ export default async function PromptsPage({ searchParams }: PromptsPageProps) { categories={categories} tags={tags} currentFilters={params} + aiSearchEnabled={aiSearchAvailable} />
diff --git a/src/components/admin/ai-search-settings.tsx b/src/components/admin/ai-search-settings.tsx new file mode 100644 index 00000000..ffb303b9 --- /dev/null +++ b/src/components/admin/ai-search-settings.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Sparkles, Loader2, CheckCircle, AlertCircle } from "lucide-react"; +import { toast } from "sonner"; + +interface AISearchSettingsProps { + enabled: boolean; + promptsWithoutEmbeddings: number; +} + +export function AISearchSettings({ enabled, promptsWithoutEmbeddings }: AISearchSettingsProps) { + const t = useTranslations("admin"); + const [isGenerating, setIsGenerating] = useState(false); + const [result, setResult] = useState<{ success: number; failed: number } | null>(null); + + const handleGenerateEmbeddings = async () => { + setIsGenerating(true); + setResult(null); + + try { + const response = await fetch("/api/admin/embeddings", { + method: "POST", + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to generate embeddings"); + } + + const data = await response.json(); + setResult({ success: data.success, failed: data.failed }); + toast.success(t("aiSearch.generateSuccess", { count: data.success })); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to generate embeddings"); + } finally { + setIsGenerating(false); + } + }; + + if (!enabled) { + return null; + } + + return ( + + + + + {t("aiSearch.title")} + + {t("aiSearch.description")} + + +
+
+ {t("aiSearch.promptsWithoutEmbeddings")}: + {promptsWithoutEmbeddings} +
+
+ + {result && ( +
+ {result.failed === 0 ? ( + + ) : ( + + )} + + {t("aiSearch.generateResult", { success: result.success, failed: result.failed })} + +
+ )} + + +
+
+ ); +} diff --git a/src/components/admin/prompts-management.tsx b/src/components/admin/prompts-management.tsx new file mode 100644 index 00000000..94fe3755 --- /dev/null +++ b/src/components/admin/prompts-management.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Upload, Trash2, Loader2, CheckCircle, AlertCircle, Sparkles, FileText } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { toast } from "sonner"; + +interface ImportResult { + success: boolean; + imported: number; + skipped: number; + total: number; + errors: string[]; +} + +interface PromptsManagementProps { + aiSearchEnabled: boolean; + promptsWithoutEmbeddings: number; +} + +export function PromptsManagement({ aiSearchEnabled, promptsWithoutEmbeddings }: PromptsManagementProps) { + const router = useRouter(); + const t = useTranslations("admin"); + const [loading, setLoading] = useState(false); + const [deleting, setDeleting] = useState(false); + const [generating, setGenerating] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [importResult, setImportResult] = useState(null); + const [embeddingResult, setEmbeddingResult] = useState<{ success: number; failed: number } | null>(null); + + const handleImport = async () => { + setLoading(true); + setShowConfirm(false); + setImportResult(null); + + try { + const res = await fetch("/api/admin/import-prompts", { method: "POST" }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Import failed"); + } + + setImportResult(data); + + if (data.imported > 0) { + toast.success(t("prompts.importSuccess", { count: data.imported })); + router.refresh(); + } else if (data.skipped === data.total) { + toast.info(t("prompts.allSkipped")); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Import failed"); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + setDeleting(true); + setShowDeleteConfirm(false); + setImportResult(null); + + try { + const res = await fetch("/api/admin/import-prompts", { method: "DELETE" }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Delete failed"); + } + + toast.success(t("prompts.deleteSuccess", { count: data.deleted })); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Delete failed"); + } finally { + setDeleting(false); + } + }; + + const handleGenerateEmbeddings = async () => { + setGenerating(true); + setEmbeddingResult(null); + + try { + const res = await fetch("/api/admin/embeddings", { method: "POST" }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Failed to generate embeddings"); + } + + setEmbeddingResult({ success: data.success, failed: data.failed }); + toast.success(t("prompts.embeddingsSuccess", { count: data.success })); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to generate embeddings"); + } finally { + setGenerating(false); + } + }; + + return ( + <> +
+
+

{t("prompts.title")}

+

{t("prompts.description")}

+
+
+ +
+ {/* Import Row */} +
+ {t("import.fileInfo")} + + +
+ + {importResult && ( +
+ {importResult.imported > 0 ? : } + {t("prompts.importResult", { imported: importResult.imported, skipped: importResult.skipped })} +
+ )} + + {/* AI Embeddings Row */} + {aiSearchEnabled && ( + <> +
+ + {t("aiSearch.title")} ({promptsWithoutEmbeddings} {t("prompts.pending")}) + + +
+ + {embeddingResult && ( +
+ {embeddingResult.failed === 0 ? : } + {t("prompts.embeddingsResult", { success: embeddingResult.success, failed: embeddingResult.failed })} +
+ )} + + )} +
+ + + + + {t("prompts.importConfirmTitle")} + {t("prompts.importConfirmDescription")} + + + {t("prompts.cancel")} + {t("prompts.confirm")} + + + + + + + + {t("prompts.deleteConfirmTitle")} + {t("prompts.deleteConfirmDescription")} + + + {t("prompts.cancel")} + + {t("prompts.delete")} + + + + + + ); +} diff --git a/src/components/admin/webhooks-table.tsx b/src/components/admin/webhooks-table.tsx index e4918393..3605c7fd 100644 --- a/src/components/admin/webhooks-table.tsx +++ b/src/components/admin/webhooks-table.tsx @@ -424,23 +424,19 @@ export function WebhooksTable({ webhooks: initialWebhooks }: WebhooksTableProps) ); return ( - - -
-
- - - {t("title")} - - {t("description")} -
- - - - + <> +
+
+

{t("title")}

+

{t("description")}

+
+ + + + {t("addTitle")} @@ -457,9 +453,9 @@ export function WebhooksTable({ webhooks: initialWebhooks }: WebhooksTableProps) -
- - +
+ +
{webhooks.length === 0 ? (
{t("empty")} @@ -528,7 +524,7 @@ export function WebhooksTable({ webhooks: initialWebhooks }: WebhooksTableProps) )} - +
{/* Edit Dialog */} @@ -548,6 +544,6 @@ export function WebhooksTable({ webhooks: initialWebhooks }: WebhooksTableProps) - + ); } diff --git a/src/components/prompts/prompt-filters.tsx b/src/components/prompts/prompt-filters.tsx index fe1f44e7..faf956e0 100644 --- a/src/components/prompts/prompt-filters.tsx +++ b/src/components/prompts/prompt-filters.tsx @@ -12,7 +12,8 @@ import { SelectValue, } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; -import { X } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { X, Sparkles } from "lucide-react"; interface PromptFiltersProps { categories: Array<{ @@ -33,12 +34,14 @@ interface PromptFiltersProps { category?: string; tag?: string; sort?: string; + ai?: string; }; + aiSearchEnabled?: boolean; } const promptTypes = ["TEXT", "STRUCTURED", "IMAGE", "VIDEO", "AUDIO"]; -export function PromptFilters({ categories, tags, currentFilters }: PromptFiltersProps) { +export function PromptFilters({ categories, tags, currentFilters, aiSearchEnabled }: PromptFiltersProps) { const router = useRouter(); const searchParams = useSearchParams(); const t = useTranslations(); @@ -90,6 +93,21 @@ export function PromptFilters({ categories, tags, currentFilters }: PromptFilter />
+ {/* AI Search Toggle */} + {aiSearchEnabled && ( +
+ + updateFilter("ai", checked ? "1" : null)} + /> +
+ )} + {/* Type filter */}
diff --git a/src/components/prompts/prompt-form.tsx b/src/components/prompts/prompt-form.tsx index 98f74e95..da805045 100644 --- a/src/components/prompts/prompt-form.tsx +++ b/src/components/prompts/prompt-form.tsx @@ -1,12 +1,13 @@ "use client"; -import { useState } from "react"; +import { useState, useRef } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Loader2 } from "lucide-react"; +import { VariableToolbar } from "./variable-toolbar"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -28,7 +29,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; -import { CodeEditor } from "@/components/ui/code-editor"; +import { CodeEditor, type CodeEditorHandle } from "@/components/ui/code-editor"; import { toast } from "sonner"; import { prettifyJson } from "@/lib/format"; @@ -98,6 +99,36 @@ export function PromptForm({ categories, tags, initialData, promptId, mode = "cr const promptType = form.watch("type"); const structuredFormat = form.watch("structuredFormat"); const requiresMediaUpload = form.watch("requiresMediaUpload"); + const textareaRef = useRef(null); + const codeEditorRef = useRef(null); + + const insertVariable = (variable: string) => { + // For structured prompts using Monaco editor + if (promptType === "STRUCTURED" && codeEditorRef.current) { + codeEditorRef.current.insertAtCursor(variable); + return; + } + + // For text prompts using textarea + const textarea = textareaRef.current; + const currentContent = form.getValues("content"); + + if (textarea) { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const newContent = currentContent.slice(0, start) + variable + currentContent.slice(end); + form.setValue("content", newContent); + + // Set cursor position after inserted variable + setTimeout(() => { + textarea.focus(); + textarea.setSelectionRange(start + variable.length, start + variable.length); + }, 0); + } else { + // Fallback: append to end + form.setValue("content", currentContent + variable); + } + }; async function onSubmit(data: PromptFormValues) { setIsLoading(true); @@ -185,23 +216,38 @@ export function PromptForm({ categories, tags, initialData, promptId, mode = "cr {promptType === "STRUCTURED" ? ( - +
+ + +
) : ( -