mirror of
https://github.com/f/awesome-chatgpt-prompts.git
synced 2026-02-12 15:52:47 +00:00
chore(messages): Add AI Search functionality to prompts management pages
This commit is contained in:
@@ -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
|
||||
# GITHUB_CLIENT_SECRET=your_client_secret
|
||||
|
||||
# AI Search (optional - enable aiSearch in prompts.config.ts)
|
||||
# OPENAI_API_KEY=your_openai_api_key
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -53,5 +53,7 @@ export default defineConfig({
|
||||
categories: true,
|
||||
// Enable tags
|
||||
tags: true,
|
||||
// Enable AI-powered semantic search (requires OPENAI_API_KEY)
|
||||
aiSearch: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
<Webhook className="h-4 w-4" />
|
||||
{t("tabs.webhooks")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="import" className="gap-2">
|
||||
<Upload className="h-4 w-4" />
|
||||
{t("tabs.import")}
|
||||
<TabsTrigger value="prompts" className="gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
{t("tabs.prompts")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -173,8 +187,11 @@ export default async function AdminPage() {
|
||||
<WebhooksTable webhooks={webhooks} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="import">
|
||||
<ImportPrompts />
|
||||
<TabsContent value="prompts">
|
||||
<PromptsManagement
|
||||
aiSearchEnabled={aiSearchEnabled}
|
||||
promptsWithoutEmbeddings={promptsWithoutEmbeddings}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
33
src/app/api/admin/embeddings/route.ts
Normal file
33
src/app/api/admin/embeddings/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
39
src/app/api/search/ai/route.ts
Normal file
39
src/app/api/search/ai/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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}
|
||||
/>
|
||||
</aside>
|
||||
<main className="flex-1 min-w-0">
|
||||
|
||||
98
src/components/admin/ai-search-settings.tsx
Normal file
98
src/components/admin/ai-search-settings.tsx
Normal file
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
{t("aiSearch.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("aiSearch.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{t("aiSearch.promptsWithoutEmbeddings")}: </span>
|
||||
<span className="font-medium">{promptsWithoutEmbeddings}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{result.failed === 0 ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-amber-500" />
|
||||
)}
|
||||
<span>
|
||||
{t("aiSearch.generateResult", { success: result.success, failed: result.failed })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateEmbeddings}
|
||||
disabled={isGenerating || promptsWithoutEmbeddings === 0}
|
||||
className="w-full"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{t("aiSearch.generating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
{t("aiSearch.generateButton")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
212
src/components/admin/prompts-management.tsx
Normal file
212
src/components/admin/prompts-management.tsx
Normal file
@@ -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<ImportResult | null>(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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("prompts.title")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("prompts.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
{/* Import Row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground flex-1">{t("import.fileInfo")}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={loading || deleting || generating}
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Upload className="h-4 w-4 mr-2" />{t("prompts.import")}</>}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={loading || deleting || generating}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{importResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{importResult.imported > 0 ? <CheckCircle className="h-4 w-4 text-green-500" /> : <AlertCircle className="h-4 w-4 text-amber-500" />}
|
||||
<span>{t("prompts.importResult", { imported: importResult.imported, skipped: importResult.skipped })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Embeddings Row */}
|
||||
{aiSearchEnabled && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 pt-3 border-t">
|
||||
<span className="text-sm text-muted-foreground flex-1">
|
||||
{t("aiSearch.title")} <span className="tabular-nums">({promptsWithoutEmbeddings} {t("prompts.pending")})</span>
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleGenerateEmbeddings}
|
||||
disabled={loading || deleting || generating || promptsWithoutEmbeddings === 0}
|
||||
>
|
||||
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Sparkles className="h-4 w-4 mr-2" />{t("prompts.generateEmbeddings")}</>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{embeddingResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{embeddingResult.failed === 0 ? <CheckCircle className="h-4 w-4 text-green-500" /> : <AlertCircle className="h-4 w-4 text-amber-500" />}
|
||||
<span>{t("prompts.embeddingsResult", { success: embeddingResult.success, failed: embeddingResult.failed })}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("prompts.importConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("prompts.importConfirmDescription")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("prompts.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleImport}>{t("prompts.confirm")}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("prompts.deleteConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("prompts.deleteConfirmDescription")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("prompts.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive hover:bg-destructive/90 text-white">
|
||||
{t("prompts.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -424,23 +424,19 @@ export function WebhooksTable({ webhooks: initialWebhooks }: WebhooksTableProps)
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Webhook className="h-5 w-5" />
|
||||
{t("title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("description")}</CardDescription>
|
||||
</div>
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => { resetForm(); setIsCreateOpen(true); }}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("add")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("title")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => { resetForm(); setIsCreateOpen(true); }}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("add")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("addTitle")}</DialogTitle>
|
||||
@@ -457,9 +453,9 @@ export function WebhooksTable({ webhooks: initialWebhooks }: WebhooksTableProps)
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
{webhooks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("empty")}
|
||||
@@ -528,7 +524,7 @@ export function WebhooksTable({ webhooks: initialWebhooks }: WebhooksTableProps)
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
@@ -548,6 +544,6 @@ export function WebhooksTable({ webhooks: initialWebhooks }: WebhooksTableProps)
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI Search Toggle */}
|
||||
{aiSearchEnabled && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<Label className="text-xs flex items-center gap-1.5 cursor-pointer" htmlFor="ai-search">
|
||||
<Sparkles className="h-3 w-3 text-primary" />
|
||||
{t("search.aiSearch")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="ai-search"
|
||||
checked={currentFilters.ai === "1"}
|
||||
onCheckedChange={(checked) => updateFilter("ai", checked ? "1" : null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type filter */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("prompts.promptType")}</Label>
|
||||
|
||||
@@ -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<HTMLTextAreaElement>(null);
|
||||
const codeEditorRef = useRef<CodeEditorHandle>(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
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{promptType === "STRUCTURED" ? (
|
||||
<CodeEditor
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
language={structuredFormat?.toLowerCase() as "json" | "yaml" || "json"}
|
||||
placeholder={
|
||||
structuredFormat === "JSON"
|
||||
? '{\n "name": "My Workflow",\n "steps": []\n}'
|
||||
: 'name: My Workflow\nsteps:\n - step: first\n prompt: "..."'
|
||||
}
|
||||
minHeight="350px"
|
||||
/>
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<VariableToolbar onInsert={insertVariable} />
|
||||
<CodeEditor
|
||||
ref={codeEditorRef}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
language={structuredFormat?.toLowerCase() as "json" | "yaml" || "json"}
|
||||
placeholder={
|
||||
structuredFormat === "JSON"
|
||||
? '{\n "name": "My Workflow",\n "steps": []\n}'
|
||||
: 'name: My Workflow\nsteps:\n - step: first\n prompt: "..."'
|
||||
}
|
||||
minHeight="350px"
|
||||
className="border-0 rounded-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
placeholder={t("contentPlaceholder")}
|
||||
className="min-h-[200px] font-mono"
|
||||
{...field}
|
||||
/>
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<VariableToolbar onInsert={insertVariable} />
|
||||
<Textarea
|
||||
ref={(el) => {
|
||||
textareaRef.current = el;
|
||||
if (typeof field.ref === 'function') field.ref(el);
|
||||
}}
|
||||
name={field.name}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder={t("contentPlaceholder")}
|
||||
className="min-h-[200px] font-mono border-0 rounded-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FormControl>
|
||||
{promptType === "STRUCTURED" && (
|
||||
|
||||
96
src/components/prompts/variable-toolbar.tsx
Normal file
96
src/components/prompts/variable-toolbar.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Variable, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
interface VariableToolbarProps {
|
||||
onInsert: (variable: string) => void;
|
||||
}
|
||||
|
||||
export function VariableToolbar({ onInsert }: VariableToolbarProps) {
|
||||
const t = useTranslations("prompts");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [defaultValue, setDefaultValue] = useState("");
|
||||
|
||||
const handleInsert = () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
const variable = defaultValue.trim()
|
||||
? `\${${name.trim()}:${defaultValue.trim()}}`
|
||||
: `\${${name.trim()}}`;
|
||||
|
||||
onInsert(variable);
|
||||
setName("");
|
||||
setDefaultValue("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && name.trim()) {
|
||||
e.preventDefault();
|
||||
handleInsert();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 p-1 border-b bg-muted/30">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5">
|
||||
<Variable className="h-3.5 w-3.5" />
|
||||
{t("insertVariable")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="start">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="var-name" className="text-xs">{t("variableName")}</Label>
|
||||
<Input
|
||||
id="var-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="topic"
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="var-default" className="text-xs">{t("variableDefault")}</Label>
|
||||
<Input
|
||||
id="var-default"
|
||||
value={defaultValue}
|
||||
onChange={(e) => setDefaultValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("variableDefaultPlaceholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{name ? (defaultValue ? `\${${name}:${defaultValue}}` : `\${${name}}`) : "${variable}"}
|
||||
</code>
|
||||
<Button size="sm" onClick={handleInsert} disabled={!name.trim()} className="h-7">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
{t("insert")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
{t("variableHint")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import Editor, { type OnMount } from "@monaco-editor/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback, useRef, useEffect, memo } from "react";
|
||||
import { useCallback, useRef, useEffect, memo, forwardRef, useImperativeHandle } from "react";
|
||||
|
||||
export interface CodeEditorHandle {
|
||||
insertAtCursor: (text: string) => void;
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
value: string;
|
||||
@@ -15,7 +19,7 @@ interface CodeEditorProps {
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
function CodeEditorInner({
|
||||
const CodeEditorInner = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditorInner({
|
||||
value,
|
||||
onChange,
|
||||
language,
|
||||
@@ -23,7 +27,7 @@ function CodeEditorInner({
|
||||
className,
|
||||
minHeight = "300px",
|
||||
debounceMs = 0,
|
||||
}: CodeEditorProps) {
|
||||
}, ref) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -39,6 +43,23 @@ function CodeEditorInner({
|
||||
editorRef.current = editor;
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
insertAtCursor: (text: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (editor) {
|
||||
const selection = editor.getSelection();
|
||||
if (selection) {
|
||||
editor.executeEdits("insert", [{
|
||||
range: selection,
|
||||
text,
|
||||
forceMoveMarkers: true,
|
||||
}]);
|
||||
editor.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
}), []);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: string | undefined) => {
|
||||
const val = newValue || "";
|
||||
@@ -111,7 +132,7 @@ function CodeEditorInner({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Memoize to prevent re-renders when parent state changes
|
||||
// Only re-render when value, language, placeholder, className, minHeight, or debounceMs change
|
||||
|
||||
214
src/lib/ai/embeddings.ts
Normal file
214
src/lib/ai/embeddings.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import OpenAI from "openai";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
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 });
|
||||
}
|
||||
return openai;
|
||||
}
|
||||
|
||||
const EMBEDDING_MODEL = "text-embedding-3-small";
|
||||
const EMBEDDING_DIMENSIONS = 1536;
|
||||
|
||||
export async function generateEmbedding(text: string): Promise<number[]> {
|
||||
const client = getOpenAIClient();
|
||||
|
||||
const response = await client.embeddings.create({
|
||||
model: EMBEDDING_MODEL,
|
||||
input: text,
|
||||
});
|
||||
|
||||
return response.data[0].embedding;
|
||||
}
|
||||
|
||||
export async function generatePromptEmbedding(promptId: string): Promise<void> {
|
||||
const config = await getConfig();
|
||||
if (!config.features.aiSearch) return;
|
||||
|
||||
const prompt = await db.prompt.findUnique({
|
||||
where: { id: promptId },
|
||||
select: { title: true, description: true, content: true },
|
||||
});
|
||||
|
||||
if (!prompt) return;
|
||||
|
||||
// Combine title, description, and content for embedding
|
||||
const textToEmbed = [
|
||||
prompt.title,
|
||||
prompt.description || "",
|
||||
prompt.content,
|
||||
].join("\n\n").trim();
|
||||
|
||||
try {
|
||||
const embedding = await generateEmbedding(textToEmbed);
|
||||
|
||||
await db.prompt.update({
|
||||
where: { id: promptId },
|
||||
data: { embedding },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate embedding for prompt ${promptId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateAllEmbeddings(): Promise<{ success: number; failed: number }> {
|
||||
const config = await getConfig();
|
||||
if (!config.features.aiSearch) {
|
||||
throw new Error("AI Search is not enabled");
|
||||
}
|
||||
|
||||
const prompts = await db.prompt.findMany({
|
||||
where: {
|
||||
embedding: { equals: Prisma.DbNull },
|
||||
isPrivate: false,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const prompt of prompts) {
|
||||
try {
|
||||
await generatePromptEmbedding(prompt.id);
|
||||
success++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success, failed };
|
||||
}
|
||||
|
||||
function cosineSimilarity(a: number[], b: number[]): number {
|
||||
let dotProduct = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dotProduct += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
||||
}
|
||||
|
||||
export interface SemanticSearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
content: string;
|
||||
similarity: number;
|
||||
author: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
category: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
} | null;
|
||||
tags: Array<{
|
||||
tag: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string;
|
||||
};
|
||||
}>;
|
||||
voteCount: number;
|
||||
type: string;
|
||||
structuredFormat: string | null;
|
||||
mediaUrl: string | null;
|
||||
isPrivate: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export async function semanticSearch(
|
||||
query: string,
|
||||
limit: number = 20
|
||||
): Promise<SemanticSearchResult[]> {
|
||||
const config = await getConfig();
|
||||
if (!config.features.aiSearch) {
|
||||
throw new Error("AI Search is not enabled");
|
||||
}
|
||||
|
||||
// Generate embedding for the query
|
||||
const queryEmbedding = await generateEmbedding(query);
|
||||
|
||||
// Fetch all public prompts with embeddings
|
||||
const prompts = await db.prompt.findMany({
|
||||
where: {
|
||||
isPrivate: false,
|
||||
embedding: { not: Prisma.DbNull },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
content: true,
|
||||
type: true,
|
||||
structuredFormat: true,
|
||||
mediaUrl: true,
|
||||
isPrivate: true,
|
||||
createdAt: true,
|
||||
embedding: true,
|
||||
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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 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,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by similarity and return top results
|
||||
scoredPrompts.sort((a, b) => b.similarity - a.similarity);
|
||||
|
||||
return scoredPrompts.slice(0, limit).map(({ _count, embedding, ...rest }) => rest);
|
||||
}
|
||||
|
||||
export async function isAISearchEnabled(): Promise<boolean> {
|
||||
const config = await getConfig();
|
||||
return config.features.aiSearch === true && !!process.env.OPENAI_API_KEY;
|
||||
}
|
||||
@@ -132,13 +132,20 @@ async function buildAuthConfig() {
|
||||
token.locale = (user as User).locale;
|
||||
}
|
||||
|
||||
// Refresh user data from database on update or if username is missing
|
||||
if (trigger === "update" || !token.username) {
|
||||
// Always verify user exists in database
|
||||
if (token.id) {
|
||||
const dbUser = await db.user.findUnique({
|
||||
where: { id: token.id as string },
|
||||
select: { role: true, username: true, locale: true },
|
||||
});
|
||||
if (dbUser) {
|
||||
|
||||
// User no longer exists - invalidate token
|
||||
if (!dbUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update token with latest user data
|
||||
if (trigger === "update" || !token.username) {
|
||||
token.role = dbUser.role;
|
||||
token.username = dbUser.username;
|
||||
token.locale = dbUser.locale;
|
||||
@@ -148,6 +155,10 @@ async function buildAuthConfig() {
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
// If token is null/invalid, return empty session
|
||||
if (!token) {
|
||||
return { ...session, user: undefined };
|
||||
}
|
||||
if (token && session.user) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.role = token.role as string;
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface FeaturesConfig {
|
||||
changeRequests: boolean;
|
||||
categories: boolean;
|
||||
tags: boolean;
|
||||
aiSearch?: boolean;
|
||||
}
|
||||
|
||||
export interface PromptsConfig {
|
||||
@@ -96,6 +97,7 @@ export async function getConfig(): Promise<PromptsConfig> {
|
||||
changeRequests: true,
|
||||
categories: true,
|
||||
tags: true,
|
||||
aiSearch: false,
|
||||
},
|
||||
};
|
||||
return cachedConfig;
|
||||
|
||||
Reference in New Issue
Block a user