feat(docs): update documentation to reflect direct browser connections for third-party prompts and WebDAV

This commit is contained in:
HouYunFei
2026-06-26 15:56:55 +08:00
parent bd0ad0aebf
commit ef7a218ba6
15 changed files with 257 additions and 385 deletions
+1
View File
@@ -4,6 +4,7 @@
+ [新增] 渠道兼容Gemini格式。
+ [新增] 新增Codex App插件支持。
+ [调整] Next.js -> Vite,前端构建工具迁移。
## v0.4.0 - 2026-06-16
+2 -2
View File
@@ -28,7 +28,7 @@
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布;
- Codex App 插件:提供 Codex app 插件,安装后会自动注册 MCP 并尝试拉起本地 Agent。
- 提示词库:Next.js route 抓取多个 GitHub 开源项目,并缓存在运行实例内存中
- 提示词库:浏览器前端直连多个 GitHub 开源项目,并缓存到 IndexedDB
完整功能说明见 [功能介绍](docs/content/docs/overview/features.mdx)。
@@ -37,7 +37,7 @@
## 技术栈
- 前端:Next.js、React、TypeScript、Tailwind CSS、Ant Design、Zustand、TanStack Query。
- 少量 Next.js Route:第三方提示词内存缓存、WebDAV 可选代理
- 前端:提示词库、WebDAV 和 AI 接口都由浏览器前端直连
- 部署:Vercel 或 Docker。
## 快速开始
@@ -23,7 +23,7 @@ http://localhost:3000
## 2. 配置模型
打开右上角配置弹窗,填写自己的 `Base URL`、`API Key` 和模型名。第三方提示词由 Next.js route 拉取并缓存在运行实例内存中;WebDAV 可选择前端直连或 Next.js 转发
打开右上角配置弹窗,填写自己的 `Base URL`、`API Key` 和模型名。第三方提示词和 WebDAV 都由浏览器前端直连
## 3. 启动文档站
@@ -36,5 +36,5 @@ bun run dev
## 常见场景
- 改画布、页面和交互:主要看 `web/`
- 改提示词缓存或 WebDAV 代理:主要看 `web/src/app/api/` 和 `web/src/app/webdav-proxy/`
- 改提示词缓存:主要看 `web/src/services/api/prompts.ts`
- 改文档站内容:主要看 `docs/content/docs/`
+1 -1
View File
@@ -49,4 +49,4 @@ docker compose -f docker-compose.local.yml up -d --build
## 数据说明
当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由 Next.js route 拉取后缓存在运行实例内存里,不需要额外挂载数据目录。
当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。
+2 -2
View File
@@ -125,7 +125,7 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
- 查看远程提示词源。
- 触发读取内置远程提示词源。
当前内置远程源包括多个 GPT Image / GPT-4o / Nano Banana Pro 相关提示词仓库,由 Next.js route 拉取并缓存在当前运行实例内存中
当前内置远程源包括多个 GPT Image / GPT-4o / Nano Banana Pro 相关提示词仓库,由浏览器前端直连拉取并缓存到 IndexedDB
## 素材
@@ -146,7 +146,7 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
- 当前版本不需要账号登录,也不再提供后台管理页面。
- 配置与用户偏好弹窗支持多个 OpenAI 兼容渠道、默认模型、生成偏好和 WebDAV 同步设置。
- 第三方提示词WebDAV 可使用少量 Next.js routeAI 接口不经过项目后端代理。
- 第三方提示词WebDAV 和 AI 接口都由浏览器前端直连,不经过项目后端代理。
## 当前限制
+1 -1
View File
@@ -39,7 +39,7 @@ docker run --rm -p 3000:3000 infinite-canvas
## 首次使用建议
- 先打开右上角配置弹窗,填入自己的 `Base URL`、`API Key` 和模型名。
- 如果需要提示词仓库内容,打开 `/prompts` 会通过 Next.js route 拉取并缓存在内存中
- 如果需要提示词仓库内容,打开 `/prompts` 会由浏览器前端直连拉取并缓存到 IndexedDB
- 如果需要跨设备同步画布、素材和生成记录,可在配置弹窗中填写 WebDAV。
## 说明
+1 -1
View File
@@ -22,7 +22,7 @@ description: 使用 Render 部署无限画布
默认使用 Render 免费 Web Service
- 空闲约 15 分钟后会休眠,下次访问会自动唤醒。
- 当前主应用数据默认保存在浏览器本地;第三方提示词缓存会随 Render 实例重启而清空,下次访问会重新拉取。
- 当前主应用数据默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取。
- 适合体验和演示,不适合长期保存正式数据。
长期使用建议优先部署到 Vercel,或自行配置 WebDAV 同步浏览器本地数据。
@@ -9,3 +9,5 @@ description: 当前版本已实现但仍需人工验证的变更项
- 配置与用户偏好:每个模型渠道新增 OpenAI / Gemini 调用格式选择,默认 OpenAI,切换默认格式时同步更新默认 Base URL;渠道 Tab 增加紧凑醒目的提醒,说明模型是否可选需要到“模型”Tab 选择。
- Gemini 调用格式:支持拉取 Gemini 模型列表,并用于文本问答、在线 Agent 工具调用、基础生图和图生图请求。
- 音频和视频生成:选择 Gemini 调用格式时先给出不支持提示,仍需使用 OpenAI 兼容渠道。
- WebDAV 同步:移除 Next.js 转发代理和连接方式选择,改为浏览器前端直接连接 WebDAV 服务。
- 提示词库:移除 Next.js route 抓取和服务端内存缓存,改为浏览器前端直连 GitHub 原始文件并缓存到 IndexedDB。
+2 -3
View File
@@ -25,11 +25,10 @@ description: 安全漏洞提交和负责任披露说明
- 清晰的复现步骤。
- 漏洞影响、攻击场景和触发条件。
- 已脱敏的日志、截图或 PoC。
- 是否涉及浏览器本地存储、API Key、WebDAV 同步、AI 渠道配置或代理接口。
- 是否涉及浏览器本地存储、API Key、WebDAV 同步、AI 渠道配置或接口请求
## 范围说明
项目会优先处理由本仓库代码或默认配置导致的 XSS、敏感信息泄露、文件处理、导入导出、WebDAV 代理、权限控制和供应链风险。
项目会优先处理由本仓库代码或默认配置导致的 XSS、敏感信息泄露、文件处理、导入导出、WebDAV 同步、权限控制和供应链风险。
第三方模型服务、托管平台、浏览器插件、用户自行泄露的 API Key、没有实际利用路径的安全头建议、纯社工钓鱼或账号找回问题通常不属于本项目漏洞范围。
-258
View File
@@ -1,258 +0,0 @@
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";
}
-59
View File
@@ -1,59 +0,0 @@
import { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const WEBDAV_PROXY_TIMEOUT_MS = 120000;
export async function POST(request: NextRequest) {
const target = request.headers.get("x-webdav-target") || "";
const method = (request.headers.get("x-webdav-method") || "GET").toUpperCase();
if (!target) return new Response("Missing x-webdav-target", { status: 400 });
let url: URL;
try {
url = new URL(target);
} catch {
return new Response("Invalid x-webdav-target", { status: 400 });
}
if (url.protocol !== "http:" && url.protocol !== "https:") return new Response("Unsupported WebDAV target", { status: 400 });
const headers = new Headers();
copyHeader(request, headers, "x-webdav-authorization", "Authorization");
copyHeader(request, headers, "x-webdav-depth", "Depth");
copyHeader(request, headers, "x-webdav-destination", "Destination");
copyHeader(request, headers, "x-webdav-overwrite", "Overwrite");
copyHeader(request, headers, "x-webdav-content-type", "Content-Type");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), WEBDAV_PROXY_TIMEOUT_MS);
try {
const body = method === "GET" || method === "HEAD" ? undefined : await request.arrayBuffer();
console.log(`[webdav-proxy] ${method} ${url.href} ${body?.byteLength || 0}B`);
const response = await fetch(url, { method, headers, body: body?.byteLength ? body : undefined, signal: controller.signal });
console.log(`[webdav-proxy] ${method} ${url.href} -> ${response.status}`);
return new Response(method === "HEAD" ? null : response.body, {
status: response.status,
headers: responseHeaders(response.headers),
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") return new Response("WebDAV proxy timeout", { status: 504 });
return new Response(error instanceof Error ? error.message : "WebDAV proxy error", { status: 502 });
} finally {
clearTimeout(timer);
}
}
function copyHeader(request: NextRequest, headers: Headers, from: string, to: string) {
const value = request.headers.get(from);
if (value) headers.set(to, value);
}
function responseHeaders(headers: Headers) {
const result = new Headers();
["content-type", "etag", "last-modified", "dav"].forEach((key) => {
const value = headers.get(key);
if (value) result.set(key, value);
});
return result;
}
+2 -13
View File
@@ -1,6 +1,6 @@
"use client";
import { App, Button, Form, Input, Modal, Progress, Segmented, Select, Tabs } from "antd";
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
import { CircleAlert, Cloud, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
import { useState } from "react";
@@ -385,22 +385,11 @@ export function AppConfigModal() {
<Cloud className="size-4" />
WebDAV
</div>
<div className="mt-1 text-xs text-stone-500"> AI API Key CORS Next.js </div>
<div className="mt-1 text-xs text-stone-500"> AI API Key WebDAV </div>
</div>
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="连接方式" className="mb-4 md:col-span-2">
<Segmented
block
value={webdav.proxyMode}
onChange={(value) => updateWebdavConfig("proxyMode", value as typeof webdav.proxyMode)}
options={[
{ label: "前端直连", value: "direct" },
{ label: "Next.js 转发", value: "nextjs" },
]}
/>
</Form.Item>
<Form.Item label="WebDAV 地址" className="mb-4">
<Input value={webdav.url} placeholder="https://nas.example.com/webdav" onChange={(event) => updateWebdavConfig("url", event.target.value)} />
</Form.Item>
+239 -12
View File
@@ -1,4 +1,6 @@
import { compactApiParams, serializeApiParams } from "@/services/api/request";
"use client";
import localforage from "localforage";
export type Prompt = {
id: string;
@@ -13,6 +15,12 @@ export type Prompt = {
updatedAt: string;
};
type PromptCategory = {
category: string;
githubUrl: string;
build: () => Promise<Omit<Prompt, "category" | "githubUrl">[]>;
};
export const ALL_PROMPTS_OPTION = "全部";
export type PromptListResponse = {
@@ -22,19 +30,238 @@ export type PromptListResponse = {
total: number;
};
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page, pageSize }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
const params = serializeApiParams(
compactApiParams({
...(keyword ? { keyword } : {}),
...(tag.length ? { tag } : {}),
...(category !== ALL_PROMPTS_OPTION ? { category } : {}),
...(page ? { page } : {}),
...(pageSize ? { pageSize } : {}),
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 promptCacheKey = "third-party-prompts";
const promptCacheStore = localforage.createInstance({ name: "infinite-canvas", storeName: "prompt_cache" });
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 loadingPrompts: Promise<Prompt[]> | null = null;
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page = 1, pageSize = 20 }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
const items = await getPrompts();
const normalizedKeyword = keyword.trim().toLowerCase();
const normalizedPage = Math.max(1, page);
const normalizedPageSize = Math.max(1, Math.min(100, pageSize));
const withoutTagFilter = filterPrompts(items, { keyword: normalizedKeyword, category, tags: [] });
const filtered = filterPrompts(items, { keyword: normalizedKeyword, category, tags: tag });
return {
items: filtered.slice((normalizedPage - 1) * normalizedPageSize, normalizedPage * normalizedPageSize),
tags: collectTags(withoutTagFilter),
categories: categories.map((item) => item.category),
total: filtered.length,
};
}
async function getPrompts() {
const cached = await promptCacheStore.getItem<{ items?: Prompt[]; fetchedAt?: number }>(promptCacheKey);
if (cached?.items?.length && cached.fetchedAt && Date.now() - cached.fetchedAt < cacheTtlMs) return cached.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 response = await fetch(`/api/prompts${params.size ? `?${params}` : ""}`);
if (!response.ok) throw new Error("获取提示词失败");
return (await response.json()) as PromptListResponse;
const items = settled.flat();
await promptCacheStore.setItem(promptCacheKey, { 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";
}
export function formatPromptDate(value: string) {
+2 -29
View File
@@ -55,7 +55,7 @@ async function ensureWebdavSubdirectory(config: WebdavSyncConfig, path: string)
async function ensureWebdavDirectoryPath(config: WebdavSyncConfig, directory: string) {
const parts = normalizePath(directory).split("/").filter(Boolean);
const cacheKey = `${config.proxyMode}:${config.url}:${parts.join("/")}`;
const cacheKey = `${config.url}:${parts.join("/")}`;
if (ensuredDirectories.has(cacheKey)) return;
let path = "";
for (const part of parts) {
@@ -79,10 +79,9 @@ async function webdavFetch(config: WebdavSyncConfig, path: string, init: Request
const timer = window.setTimeout(() => controller.abort(), WEBDAV_REQUEST_TIMEOUT_MS);
try {
const url = buildWebdavUrl(config, path);
if (config.proxyMode === "nextjs") return await fetch("/webdav-proxy", { method: "POST", headers: proxyHeaders(url, init.method || "GET", headers), body: proxyBody(init), signal: controller.signal });
return await fetch(url, { ...init, headers, signal: controller.signal });
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw new Error("WebDAV 请求超时,请检查网络、代理或远端服务状态");
if (error instanceof Error && error.name === "AbortError") throw new Error("WebDAV 请求超时,请检查网络或远端服务状态");
if (error instanceof TypeError) throw new Error("无法连接 WebDAV,请检查地址、HTTPS 证书、CORS 或网络状态");
throw error;
} finally {
@@ -90,32 +89,6 @@ async function webdavFetch(config: WebdavSyncConfig, path: string, init: Request
}
}
function proxyHeaders(target: string, method: string, headers: Headers) {
const proxyHeaders = new Headers({
"x-webdav-target": target,
"x-webdav-method": method,
});
copyProxyHeader(headers, proxyHeaders, "Authorization", "x-webdav-authorization");
copyProxyHeader(headers, proxyHeaders, "Depth", "x-webdav-depth");
copyProxyHeader(headers, proxyHeaders, "Destination", "x-webdav-destination");
copyProxyHeader(headers, proxyHeaders, "Overwrite", "x-webdav-overwrite");
copyProxyHeader(headers, proxyHeaders, "Content-Type", "x-webdav-content-type");
const contentType = headers.get("Content-Type");
if (contentType) proxyHeaders.set("Content-Type", contentType);
return proxyHeaders;
}
function copyProxyHeader(from: Headers, to: Headers, source: string, target: string) {
const value = from.get(source);
if (value) to.set(target, value);
}
function proxyBody(init: RequestInit) {
const method = (init.method || "GET").toUpperCase();
if (method === "GET" || method === "HEAD") return undefined;
return init.body || undefined;
}
function buildWebdavUrl(config: WebdavSyncConfig, path: string) {
const baseUrl = config.url.trim().replace(/\/+$/, "");
const remotePath = [normalizePath(config.directory), normalizePath(path)].filter(Boolean).join("/");
-2
View File
@@ -48,7 +48,6 @@ export type AiConfig = {
};
export type WebdavSyncConfig = {
proxyMode: "direct" | "nextjs";
url: string;
username: string;
password: string;
@@ -103,7 +102,6 @@ export const defaultConfig: AiConfig = {
};
export const defaultWebdavSyncConfig: WebdavSyncConfig = {
proxyMode: "direct",
url: "",
username: "",
password: "",