chore(messages): Add AI Search functionality to prompts management pages

This commit is contained in:
Fatih Kadir Akın
2025-12-11 02:25:24 +03:00
parent cbe6987005
commit 5a7eed664f
22 changed files with 1177 additions and 143 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -130,6 +130,12 @@
"titlePlaceholder": "Promptunuz için bir başlık girin",
"descriptionPlaceholder": "İsteğe bağlıı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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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?

View File

@@ -53,5 +53,7 @@ export default defineConfig({
categories: true,
// Enable tags
tags: true,
// Enable AI-powered semantic search (requires OPENAI_API_KEY)
aiSearch: true,
},
});

View File

@@ -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>

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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">

View 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>
);
}

View 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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>

View File

@@ -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" && (

View 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>
);
}

View File

@@ -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
View 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;
}

View File

@@ -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;

View File

@@ -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;