From ef7a218ba6c955648be0689b947f5669c3b6f043 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Fri, 26 Jun 2026 15:56:55 +0800 Subject: [PATCH] feat(docs): update documentation to reflect direct browser connections for third-party prompts and WebDAV --- CHANGELOG.md | 1 + README.md | 4 +- .../docs/backend/local-development.mdx | 4 +- docs/content/docs/overview/docker.mdx | 2 +- docs/content/docs/overview/features.mdx | 4 +- docs/content/docs/overview/quick-start.mdx | 2 +- docs/content/docs/overview/render.mdx | 2 +- docs/content/docs/progress/pending-test.mdx | 2 + docs/content/docs/support/security.mdx | 5 +- web/src/app/api/prompts/route.ts | 258 ------------------ web/src/app/webdav-proxy/route.ts | 59 ---- .../components/layout/app-config-modal.tsx | 15 +- web/src/services/api/prompts.ts | 251 ++++++++++++++++- web/src/services/webdav-sync.ts | 31 +-- web/src/stores/use-config-store.ts | 2 - 15 files changed, 257 insertions(+), 385 deletions(-) delete mode 100644 web/src/app/api/prompts/route.ts delete mode 100644 web/src/app/webdav-proxy/route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9dd26..5341381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ + [新增] 渠道兼容Gemini格式。 + [新增] 新增Codex App插件支持。 ++ [调整] Next.js -> Vite,前端构建工具迁移。 ## v0.4.0 - 2026-06-16 diff --git a/README.md b/README.md index 20d7ae4..295964c 100644 --- a/README.md +++ b/README.md @@ -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。 ## 快速开始 diff --git a/docs/content/docs/backend/local-development.mdx b/docs/content/docs/backend/local-development.mdx index 062b2de..dd4adfb 100644 --- a/docs/content/docs/backend/local-development.mdx +++ b/docs/content/docs/backend/local-development.mdx @@ -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/` diff --git a/docs/content/docs/overview/docker.mdx b/docs/content/docs/overview/docker.mdx index 6e53fc9..a3331cb 100644 --- a/docs/content/docs/overview/docker.mdx +++ b/docs/content/docs/overview/docker.mdx @@ -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 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 29cb409..69d5ffc 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -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 route,AI 接口不经过项目后端代理。 +- 第三方提示词、WebDAV 和 AI 接口都由浏览器前端直连,不经过项目后端代理。 ## 当前限制 diff --git a/docs/content/docs/overview/quick-start.mdx b/docs/content/docs/overview/quick-start.mdx index 93e976d..d82795b 100644 --- a/docs/content/docs/overview/quick-start.mdx +++ b/docs/content/docs/overview/quick-start.mdx @@ -39,7 +39,7 @@ docker run --rm -p 3000:3000 infinite-canvas ## 首次使用建议 - 先打开右上角配置弹窗,填入自己的 `Base URL`、`API Key` 和模型名。 -- 如果需要提示词仓库内容,打开 `/prompts` 会通过 Next.js route 拉取并缓存在内存中。 +- 如果需要提示词仓库内容,打开 `/prompts` 会由浏览器前端直连拉取并缓存到 IndexedDB。 - 如果需要跨设备同步画布、素材和生成记录,可在配置弹窗中填写 WebDAV。 ## 说明 diff --git a/docs/content/docs/overview/render.mdx b/docs/content/docs/overview/render.mdx index c7dabc9..69f5325 100644 --- a/docs/content/docs/overview/render.mdx +++ b/docs/content/docs/overview/render.mdx @@ -22,7 +22,7 @@ description: 使用 Render 部署无限画布 默认使用 Render 免费 Web Service: - 空闲约 15 分钟后会休眠,下次访问会自动唤醒。 -- 当前主应用数据默认保存在浏览器本地;第三方提示词缓存会随 Render 实例重启而清空,下次访问会重新拉取。 +- 当前主应用数据默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取。 - 适合体验和演示,不适合长期保存正式数据。 长期使用建议优先部署到 Vercel,或自行配置 WebDAV 同步浏览器本地数据。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index feeea32..16321f9 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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。 diff --git a/docs/content/docs/support/security.mdx b/docs/content/docs/support/security.mdx index 40d9fbf..c043eaf 100644 --- a/docs/content/docs/support/security.mdx +++ b/docs/content/docs/support/security.mdx @@ -25,11 +25,10 @@ description: 安全漏洞提交和负责任披露说明 - 清晰的复现步骤。 - 漏洞影响、攻击场景和触发条件。 - 已脱敏的日志、截图或 PoC。 -- 是否涉及浏览器本地存储、API Key、WebDAV 同步、AI 渠道配置或代理接口。 +- 是否涉及浏览器本地存储、API Key、WebDAV 同步、AI 渠道配置或接口请求。 ## 范围说明 -项目会优先处理由本仓库代码或默认配置导致的 XSS、敏感信息泄露、文件处理、导入导出、WebDAV 代理、权限控制和供应链风险。 +项目会优先处理由本仓库代码或默认配置导致的 XSS、敏感信息泄露、文件处理、导入导出、WebDAV 同步、权限控制和供应链风险。 第三方模型服务、托管平台、浏览器插件、用户自行泄露的 API Key、没有实际利用路径的安全头建议、纯社工钓鱼或账号找回问题通常不属于本项目漏洞范围。 - diff --git a/web/src/app/api/prompts/route.ts b/web/src/app/api/prompts/route.ts deleted file mode 100644 index 8585827..0000000 --- a/web/src/app/api/prompts/route.ts +++ /dev/null @@ -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[]>; -}; - -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 | 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(); - const markdowns = await Promise.all(gptImage2CaseFiles.map((file) => fetchText(gptImage2RawBase, file))); - markdowns.forEach((markdown) => collectGptImage2Cases(cases, markdown)); - const items: Omit[] = []; - 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, 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[] = []; - 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[] = []; - 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[] = []; - 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>(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 => Boolean(item)); -} - -function defaultPrompt(id: string, title: string, prompt: string, coverUrl: string, tags: string[], preview: string): Omit { - 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(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"; -} diff --git a/web/src/app/webdav-proxy/route.ts b/web/src/app/webdav-proxy/route.ts deleted file mode 100644 index 16fb961..0000000 --- a/web/src/app/webdav-proxy/route.ts +++ /dev/null @@ -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; -} diff --git a/web/src/components/layout/app-config-modal.tsx b/web/src/components/layout/app-config-modal.tsx index ea41f55..19b581e 100644 --- a/web/src/components/layout/app-config-modal.tsx +++ b/web/src/components/layout/app-config-modal.tsx @@ -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() { WebDAV 同步 -
同步画布、我的素材、生成记录和本地媒体文件,不包含 AI API Key;服务不支持 CORS 时可走 Next.js 转发。
+
同步画布、我的素材、生成记录和本地媒体文件,不包含 AI API Key;浏览器会直接连接 WebDAV 服务。
{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}
- - updateWebdavConfig("proxyMode", value as typeof webdav.proxyMode)} - options={[ - { label: "前端直连", value: "direct" }, - { label: "Next.js 转发", value: "nextjs" }, - ]} - /> - updateWebdavConfig("url", event.target.value)} /> diff --git a/web/src/services/api/prompts.ts b/web/src/services/api/prompts.ts index 09b4de0..25bb039 100644 --- a/web/src/services/api/prompts.ts +++ b/web/src/services/api/prompts.ts @@ -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[]>; +}; + 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 | 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(); + const markdowns = await Promise.all(gptImage2CaseFiles.map((file) => fetchText(gptImage2RawBase, file))); + markdowns.forEach((markdown) => collectGptImage2Cases(cases, markdown)); + const items: Omit[] = []; + 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, 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[] = []; + 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[] = []; + 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[] = []; + 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>(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 => Boolean(item)); +} + +function defaultPrompt(id: string, title: string, prompt: string, coverUrl: string, tags: string[], preview: string): Omit { + 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(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) { diff --git a/web/src/services/webdav-sync.ts b/web/src/services/webdav-sync.ts index 4ca495f..dc6dda8 100644 --- a/web/src/services/webdav-sync.ts +++ b/web/src/services/webdav-sync.ts @@ -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("/"); diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index ffc5ad9..981f45c 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -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: "",