mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
259 lines
12 KiB
TypeScript
259 lines
12 KiB
TypeScript
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 ? `` : ""].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) => ``).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";
|
||
}
|