Files
infinite-canvas/web/src/app/api/prompts/route.ts
T

259 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Prompt = {
id: string;
title: string;
coverUrl: string;
prompt: string;
tags: string[];
category: string;
githubUrl: string;
preview: string;
createdAt: string;
updatedAt: string;
};
type PromptCategory = {
category: string;
githubUrl: string;
build: () => Promise<Omit<Prompt, "category" | "githubUrl">[]>;
};
const gptImage2RawBase = "https://raw.githubusercontent.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts/main";
const awesomeGptImageRawBase = "https://raw.githubusercontent.com/ZeroLu/awesome-gpt-image/main";
const awesomeGpt4oImagePromptsBase = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main";
const youMindGptImage2RawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main";
const youMindNanoBananaProRawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main";
const davidWuGptImage2RawBase = "https://raw.githubusercontent.com/davidwuw0811-boop/awesome-gpt-image2-prompts/main";
const gptImage2CaseFiles = ["README.md", "cases/ad-creative.md", "cases/character.md", "cases/comparison.md", "cases/ecommerce.md", "cases/portrait.md", "cases/poster.md", "cases/ui.md"];
const cacheTtlMs = 1000 * 60 * 60;
const categories: PromptCategory[] = [
{ category: "gpt-image-2-prompts", githubUrl: "https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts", build: buildGptImage2Prompts },
{ category: "awesome-gpt-image", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", build: buildAwesomeGptImagePrompts },
{ category: "awesome-gpt4o-image-prompts", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", build: buildAwesomeGpt4oImagePrompts },
{ category: "youmind-gpt-image-2", githubUrl: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", build: () => buildYouMindPrompts(youMindGptImage2RawBase, "youmind-gpt-image-2", "gpt-image-2") },
{ category: "youmind-nano-banana-pro", githubUrl: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", build: () => buildYouMindPrompts(youMindNanoBananaProRawBase, "youmind-nano-banana-pro", "nano-banana-pro") },
{ category: "davidwu-gpt-image2-prompts", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", build: buildDavidWuGptImage2Prompts },
];
let memoryCache: { items: Prompt[]; fetchedAt: number } | null = null;
let loadingPrompts: Promise<Prompt[]> | null = null;
export async function GET(request: NextRequest) {
const params = request.nextUrl.searchParams;
const keyword = (params.get("keyword") || "").trim().toLowerCase();
const tags = params.getAll("tag").filter(Boolean);
const category = params.get("category") || "";
const page = Math.max(1, Number(params.get("page")) || 1);
const pageSize = Math.max(1, Math.min(100, Number(params.get("pageSize")) || 20));
const items = await getPrompts();
const withoutTagFilter = filterPrompts(items, { keyword, category, tags: [] });
const filtered = filterPrompts(items, { keyword, category, tags });
return Response.json({
items: filtered.slice((page - 1) * pageSize, page * pageSize),
tags: collectTags(withoutTagFilter),
categories: categories.map((item) => item.category),
total: filtered.length,
});
}
async function getPrompts() {
if (memoryCache && Date.now() - memoryCache.fetchedAt < cacheTtlMs) return memoryCache.items;
if (loadingPrompts) return loadingPrompts;
loadingPrompts = loadPrompts().finally(() => {
loadingPrompts = null;
});
return loadingPrompts;
}
async function loadPrompts() {
const settled = await Promise.all(
categories.map(async (category) => {
try {
const items = await category.build();
return items.map((item) => ({ ...item, category: category.category, githubUrl: category.githubUrl }));
} catch {
return [];
}
}),
);
const items = settled.flat();
memoryCache = { items, fetchedAt: Date.now() };
return items;
}
function filterPrompts(items: Prompt[], options: { keyword: string; category: string; tags: string[] }) {
return items.filter((item) => {
if (isActiveOption(options.category) && item.category !== options.category) return false;
if (options.tags.length && !options.tags.some((tag) => item.tags.includes(tag))) return false;
if (!options.keyword) return true;
return [item.title, item.prompt, item.category, ...item.tags].join(" ").toLowerCase().includes(options.keyword);
});
}
async function buildGptImage2Prompts() {
const data = (await fetchJson<{ records?: Array<{ title?: string; tweet_url?: string; image_dir?: string; category?: string; added_at?: string }> }>(gptImage2RawBase, "data/ingested_tweets.json")).records || [];
const cases = new Map<string, string>();
const markdowns = await Promise.all(gptImage2CaseFiles.map((file) => fetchText(gptImage2RawBase, file)));
markdowns.forEach((markdown) => collectGptImage2Cases(cases, markdown));
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
data.forEach((item) => {
const prompt = cases.get(item.tweet_url || "");
if (!item.title || !prompt || !item.image_dir) return;
const image = `${gptImage2RawBase}/${item.image_dir}/output.jpg`;
items.push({ id: `gpt-image-2-prompts-${leftPad(items.length + 1)}`, title: item.title, coverUrl: image, prompt, tags: tagsFromCategory(item.category || ""), preview: markdownPreview([image]), createdAt: item.added_at || "", updatedAt: item.added_at || "" });
});
return items;
}
function collectGptImage2Cases(cases: Map<string, string>, markdown: string) {
for (const match of markdown.matchAll(/### Case \d+: \[[^\]]+]\(([^)]+)\).*?\*\*Prompt:\*\*\s*\r?\n\s*```[\w-]*\r?\n(.*?)\r?\n```/gs)) {
cases.set(match[1], match[2].trim());
}
}
async function buildAwesomeGptImagePrompts() {
const markdown = await fetchText(awesomeGptImageRawBase, "README.zh-CN.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const section of splitBeforeHeading(markdown, "## ")) {
const tags = tagsFromHeading(firstMatch(section, /^##\s+(.+)$/m));
for (const block of splitBeforeHeading(section, "### ")) {
const title = firstMatch(block, /^###\s+(.+)$/m).replace(/\[([^\]]+)]\([^)]+\)/g, "$1").trim();
const prompt = firstMatch(block, /\*\*:\*\*\s*\r?\n\s*```[\w-]*\r?\n(.*?)\r?\n```/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(awesomeGptImageRawBase, block);
items.push(defaultPrompt(`awesome-gpt-image-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", tags, markdownPreview(images)));
}
}
return items;
}
async function buildAwesomeGpt4oImagePrompts() {
const markdown = await fetchText(awesomeGpt4oImagePromptsBase, "README.zh-CN.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const block of splitBeforeHeading(markdown, "### ")) {
const title = firstMatch(block, /^###\s+(.+)$/m).trim();
const prompt = firstMatch(block, /- \*\*\*\*\s*`(.*?)`/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(awesomeGpt4oImagePromptsBase, block);
items.push(defaultPrompt(`awesome-gpt4o-image-prompts-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", ["gpt4o"], markdownPreview(images)));
}
return items;
}
async function buildYouMindPrompts(baseUrl: string, idPrefix: string, modelTag: string) {
const markdown = await fetchText(baseUrl, "README_zh.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const block of splitBeforeHeading(markdown, "### ")) {
const title = firstMatch(block, /^###\s+No\.\s*\d+:\s*(.+)$/m).trim();
const prompt = firstMatch(block, /#### .*?\s*\r?\n\s*```[\w-]*\r?\n(.*?)\r?\n```/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(baseUrl, block);
items.push(defaultPrompt(`${idPrefix}-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", youMindTags(title, modelTag), markdownPreview(images)));
}
return items;
}
async function buildDavidWuGptImage2Prompts() {
const data = await fetchJson<Array<{ id?: number; title_en?: string; title_cn?: string; category?: string; category_cn?: string; prompt?: string; note?: string; author?: string; source?: string; needs_ref?: boolean; image?: string }>>(davidWuGptImage2RawBase, "prompts.json");
return data
.map((item, index) => {
const title = (item.title_cn || item.title_en || "").trim();
const prompt = (item.prompt || "").trim();
if (!title || !prompt) return null;
const image = absoluteImage(davidWuGptImage2RawBase, item.image || "");
const preview = [item.title_en, item.note, image ? `![](${image})` : ""].filter(Boolean).join("\n\n");
return defaultPrompt(`davidwu-gpt-image2-prompts-${leftPad(item.id || index + 1)}`, title, prompt, image, davidWuTags(item), preview);
})
.filter((item): item is Omit<Prompt, "category" | "githubUrl"> => Boolean(item));
}
function defaultPrompt(id: string, title: string, prompt: string, coverUrl: string, tags: string[], preview: string): Omit<Prompt, "category" | "githubUrl"> {
return { id, title, coverUrl, prompt, tags, preview, createdAt: "", updatedAt: "" };
}
async function fetchText(baseUrl: string, file: string) {
const response = await fetch(`${baseUrl}/${file}`, { cache: "no-store" });
if (!response.ok) throw new Error(`${file} 拉取失败`);
return response.text();
}
async function fetchJson<T>(baseUrl: string, file: string) {
return JSON.parse(await fetchText(baseUrl, file)) as T;
}
function splitBeforeHeading(markdown: string, prefix: string) {
const blocks: string[] = [];
let current: string[] = [];
for (const line of markdown.split("\n")) {
if (line.startsWith(prefix) && current.length) {
blocks.push(current.join("\n"));
current = [];
}
current.push(line);
}
blocks.push(current.join("\n"));
return blocks;
}
function firstMatch(value: string, pattern: RegExp) {
return pattern.exec(value)?.[1] || "";
}
function extractMarkdownImages(baseUrl: string, markdown: string) {
return Array.from(markdown.matchAll(/!\[[^\]]*]\(([^)]+)\)/g), (match) => absoluteImage(baseUrl, match[1])).filter(Boolean);
}
function absoluteImage(baseUrl: string, image: string) {
if (!image) return "";
if (/^https?:\/\//i.test(image)) return image;
return `${baseUrl}/${image.replace(/^\.?\//, "")}`;
}
function tagsFromCategory(category: string) {
return splitTags(category.replace(/\s+Cases$/i, ""), /\s*(?:&|and)\s*/);
}
function tagsFromHeading(heading: string) {
return splitTags(heading.replace(/[^\p{L}\p{N}/& ]/gu, ""), /\s*(?:\/|&||)\s*/);
}
function youMindTags(title: string, modelTag: string) {
const [, prefix] = title.match(/^(.+?) - /) || [];
return [modelTag, ...tagsFromHeading(prefix || "")];
}
function davidWuTags(item: { category_cn?: string; category?: string; author?: string; source?: string; needs_ref?: boolean }) {
const tags = splitTags([item.category_cn, item.category, item.author, item.source].filter(Boolean).join("/"), /\//);
if (item.needs_ref) tags.push("需要参考图");
return tags;
}
function splitTags(value: string, pattern: RegExp) {
return value
.split(pattern)
.map((tag) => tag.trim().toLowerCase())
.filter(Boolean);
}
function markdownPreview(images: string[]) {
return images.filter(Boolean).map((image) => `![](${image})`).join("\n\n");
}
function collectTags(items: Prompt[]) {
return Array.from(new Set(items.flatMap((item) => item.tags).filter(Boolean)));
}
function leftPad(value: number) {
return String(value).padStart(4, "0");
}
function isActiveOption(value: string) {
return value && value !== "全部" && value !== "all";
}