From d4130bbb7918791fd48627578532cfc6904f7d7a Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Fri, 17 Jul 2026 17:36:42 +0800 Subject: [PATCH] feat(prompt-sources): add custom script fetching functionality for prompt sources --- CHANGELOG.md | 2 + .../third-party-prompt-repositories.mdx | 1 - docs/content/docs/progress/pending-test.mdx | 50 ---- .../components/layout/app-config-modal.tsx | 6 + .../components/layout/client-root-init.tsx | 3 + .../layout/config-prompt-sources.tsx | 137 ++++++++++ .../layout/prompt-source-content-modal.tsx | 130 +++++++++ .../layout/prompt-source-editor-drawer.tsx | 108 ++++++++ web/src/hooks/use-prompt-source-scheduler.ts | 37 +++ web/src/services/api/prompt-source-presets.ts | 118 ++++++++ web/src/services/api/prompt-source-runtime.ts | 165 +++++++++++ web/src/services/api/prompts.ts | 256 ++++++------------ web/src/stores/use-config-store.ts | 2 +- web/src/stores/use-prompt-source-store.ts | 65 +++++ 14 files changed, 851 insertions(+), 229 deletions(-) create mode 100644 web/src/components/layout/config-prompt-sources.tsx create mode 100644 web/src/components/layout/prompt-source-content-modal.tsx create mode 100644 web/src/components/layout/prompt-source-editor-drawer.tsx create mode 100644 web/src/hooks/use-prompt-source-scheduler.ts create mode 100644 web/src/services/api/prompt-source-presets.ts create mode 100644 web/src/services/api/prompt-source-runtime.ts create mode 100644 web/src/stores/use-prompt-source-store.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d48e1d..de7e54d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased ++ [新增] 提示词来源增加自定义脚本抓取功能、支持新增来源。 + ## v0.9.0 - 2026-07-17 + [调整] 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现,画布更简洁。 diff --git a/docs/content/docs/overview/third-party-prompt-repositories.mdx b/docs/content/docs/overview/third-party-prompt-repositories.mdx index 7095325..fdb73c7 100644 --- a/docs/content/docs/overview/third-party-prompt-repositories.mdx +++ b/docs/content/docs/overview/third-party-prompt-repositories.mdx @@ -7,7 +7,6 @@ description: 当前已接入同步逻辑的第三方提示词仓库 | 地址 | 状态 | | --- | --- | -| https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts | 已实现同步逻辑 | | https://github.com/ZeroLu/awesome-gpt-image | 已实现同步逻辑 | | https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 | | https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 | diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index cbd103b..a781a33 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -4,53 +4,3 @@ description: 当前版本已实现但仍需人工验证的变更项 --- # 待测试 - -- 左侧画布面板「资产」Tab:搜索框旁「添加」按钮可上传图片/视频文件(支持多选)加入资产库;每张资产卡片悬停显示移除按钮,二次确认后删除;点击卡片仍插入到画布;需验证添加图片/视频、非图片视频文件的提示、移除确认与插入行为。 -- 左侧画布面板:支持拖拽右边缘调整宽度(记忆到本地)、通过顶栏菜单左侧的开关按钮展开/收起,展开收起有动画;需验证宽度拖拽、动画、刷新后保持展开/收起状态与宽度。 -- 左侧画布面板:「画布/资产」切换改为带滑动下划线的动画;点击「画布元素」列表项跳转到对应节点时带缓动动画(视图平滑移动缩放而非瞬移);文本/配置/视频/音频等非图片元素的图标去掉灰色底色;需验证视觉与交互。 -- 左侧画布面板:画布元素列表点击「选择」进入多选模式,可勾选/全选节点后「导出选中」为压缩包;压缩包为扁平结构——图片/视频/音频直接是对应文件(按节点名称命名、重名自动加序号),文本节点为 `.txt`,其余无法识别的节点各自导出为同名 `.json`;需验证多选、全选、导出内容与命名正确。 -- 顶栏菜单:新增「导出当前画布」,导出当前画布为压缩包(与项目列表页单个导出格式一致);需验证导出文件名与内容。 -- 画布节点插件系统:内置节点(图片/文本/视频/音频/配置/组)改为统一注册表管理,创建菜单由注册表动态生成;需验证六种内置节点的创建、渲染、缩放、连线、小地图颜色、作为生成输入等行为与之前一致。内置节点仅这六种,其余均为插件。 -- 画布节点插件系统:左上菜单新增「节点插件」,可通过 URL 安装远程插件、启用/禁用、更新、卸载;安装时有安全警告说明插件代码在页面内执行、可访问本地数据。可用 `/plugins/sticky-note.js` 安装便利贴节点验证:换色、编辑、「衍生文本节点」(演示 applyOps 增节点+连线)。 -- 画布节点插件系统:示例插件 Markdown(编辑/渲染)、HTML(沙箱 iframe 渲染,支持 `{{input}}` 注入上游文本)、SVG(渲染/编辑,可取上游文本 SVG 源码)、3D 全景(three.js CDN 动态加载,可从上游图片节点取全景图并拖拽查看);均为远程插件,需分别安装 `/plugins/markdown.js`、`/plugins/html.js`、`/plugins/svg.js`、`/plugins/panorama.js` 后验证创建、渲染、编辑及与上游节点的交互。 -- 画布节点插件系统:卸载或禁用插件后,画布上遗留的该类型节点显示「缺少插件」占位且数据保留,重新安装/启用后恢复渲染;需验证刷新后已启用插件自动加载。 -- 画布节点插件系统:每个插件为 `plugins/canvas//` 独立目录(各自 package.json + esbuild + src + README,互不耦合),`npm run build` 产物落到 `dist/.js` 并同步到 `web/public/plugins/`;需验证各插件可独立构建与安装。 -- 画布节点插件本地开发:每个插件支持 `npm run dev`(watch 自动构建并同步到 `web/public/plugins/`),配合 `web/.env.local` 的 `VITE_DEV_PLUGINS`(逗号分隔 URL)每次刷新重新拉取、无需反复安装;需验证改源码刷新页面即生效。 -- 画布节点创建:鼠标左键双击画布空白区域会在点击位置打开节点选择菜单,可创建文本、图片、视频、音频、生成配置和组节点;双击已有节点或连线不应触发菜单。 -- 本地 Agent 连接说明:新增提醒说明只有安装 Codex 插件或手动添加 MCP 后才会增加 Codex token 消耗,直接运行 `npx -y @basketikun/canvas-agent` 不会安装 MCP;需验证文案位置清晰。 -- 本地 Agent 连接说明:画布面板和配置页 Codex Tab 改为区分“Codex 插件启动画布”和“直接运行 Agent 后网页连接”两种方式,需验证文案和布局清晰。 -- 全站 Agent:Agent 面板从画布页抽离为全站常驻面板,挂在全局布局右侧,开/关只挤压左侧内容不遮挡;顶栏和画布工具条各有一个开关按钮,需验证在首页、生图、视频、素材、提示词库、画布等页面都能打开面板并保持 SSE 连接不断开。 -- 全站 Agent:新增 `site_navigate` 工具,Agent 可直接跳转 `/`、`/canvas`、`/canvas/:id`、`/image`、`/video`、`/prompts`、`/assets`、`/config`;导航直接执行不走工具确认,需验证跳转正确且面板不消失。 -- 全站 Agent:画布工具(canvas_apply_ops 等)仅在当前处于画布页时可用,不在画布页调用会返回“当前不在画布页,请先用 site_navigate 打开画布”,需验证 Agent 会先导航到画布再执行画布操作。 -- 全站 Agent:Codex 会话改为单一站点级连续线程(不再按 canvasId 分),跨页面/跨画布保持同一条对话上下文,历史记录即这条线程;本地 Agent workspace 从按画布分改为固定 `site` workspace(`~/.infinite-canvas/codex-workspaces/site`),已装 `@basketikun/canvas-agent` 的用户需更新到新版本才生效。 -- 全站 Agent:移除仅前端调用 OpenAI responses 接口的在线画布助手死代码(含 `requestToolResponse`),Agent 统一走 MCP + 本地 Codex 链路。 -- 画布节点名称:每个节点保留 `title` 名称字段,默认名称显示在节点上方,双击名称可直接编辑,节点信息弹窗和 JSON 可查看该字段。 -- 画布节点工具条:节点顶部工具条改为点击选中节点后显示,单纯鼠标移入 / 移出节点不应再频繁弹出。 -- 画布组节点:组内子节点优先响应点击和拖拽;当鼠标位于组内节点上时,应移动 / 编辑该节点,而不是移动整个组。 -- 画布组节点:工具栏新增“组”节点,普通节点拖入组区域会高亮目标组,松手后自动吸附进组;拖出组区域会解除归属,拖动组节点会一起移动组内子节点。 -- GitHub Pages:新增版本 tag 触发的前端静态站点自动发布 workflow,需在仓库 Pages 设置中选择 GitHub Actions 并验证路由刷新回退。 -- 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览。 -- Codex App 插件:精简插件 README 安装步骤,更新插件网站和作者信息,并将 `open-canvas` 调整为默认打开在线画布、仅按需启动本地前端;需重新安装后确认详情页和两种打开流程正确。 -- 本地 Codex 连接画布文档:新增 Canvas Agent、MCP、SSE 与浏览器画布交互原理说明,需确认文档导航和描述是否清晰。 -- 顶部导航:缩小桌面顶部导航栏高度,导航项下划线和内容垂直对齐需确认。 -- 配置与用户偏好:新增独立顶部导航页面,并在页面和弹窗中复用同一套配置面板;新增 Codex Tab,展示插件安装和本地 Agent 启动步骤,可配置本地 Agent 地址、Connect token 和工具执行确认开关。 -- Codex 全局连接:配置页可直接连接本地 Canvas Agent,连接状态同步到画布和顶部 OpenAI 状态图标,刷新后会按本地保存的地址和 token 自动尝试重连;需验证已连接、连接中、连接失败、断开和刷新自动重连状态。 -- 画布 Agent:Codex 旧 thread 在重启后不可用时会自动新建会话并重试,需验证进入画布直接发送消息不会再出现 `thread not loaded` 或 `no rollout found`。 -- 画布 Agent:切换不同画布时会清空并重新加载当前 canvasId 对应的 Codex 会话,需验证不会继续显示上一个画布的对话。 -- 画布 Agent:右侧 Agent 面板移除网站 / 本机模式切换,固定使用本机 Agent。 -- 配置与用户偏好:每个模型渠道新增 OpenAI / Gemini 调用格式选择,默认 OpenAI,切换默认格式时同步更新默认 Base URL;渠道 Tab 增加紧凑醒目的提醒,说明模型是否可选需要到“模型”Tab 选择。 -- Gemini 调用格式:支持拉取 Gemini 模型列表,并用于文本问答、在线 Agent 工具调用、基础生图和图生图请求。 -- Gemini 生图:生图工作台和画布生图会把尺寸配置转换为 Gemini `responseFormat.image.aspectRatio` 传入,需验证 1:1、9:16、16:9 及自定义像素尺寸的输出比例。 -- 音频和视频生成:选择 Gemini 调用格式时先给出不支持提示,仍需使用 OpenAI 兼容渠道。 -- WebDAV 同步:移除 Next.js 转发代理和连接方式选择,改为浏览器前端直接连接 WebDAV 服务。 -- 提示词库:移除 Next.js route 抓取和服务端内存缓存,改为浏览器前端直连 GitHub 原始文件并缓存到 IndexedDB。 -- 提示词库:移除已 404 的 `EvoLinkAI/awesome-gpt-image-2-API-and-Prompts` 来源。 -- 前端构建:移除 Next.js,改为 Vite + React Router,并将前端目录调整为 `pages` / `components` / `stores` / `lib` / `layouts` / `styles`,需验证首页、图片/视频工作台、素材、提示词库和画布路由跳转。 -- 前端构建:修复 TypeScript 严格检查下的构建报错。 -- Vercel 部署:新增 `web` 和 `docs` 两个子项目的 Vercel 配置,需分别验证静态前端和文档站部署。 -- Docker 部署:运行镜像从 Node serve 改为 nginx 静态托管,需验证前端路由刷新回退。 -- 环境变量:移除过期的后端 `.env.example` 示例文件。 -- 生图工作台:修复失败图片点击重试后只保存在页面状态、刷新或跳转后丢失的问题,重试成功结果会写入本地图片存储和生成记录。 -- 视频生成:任务查询接口直接返回 `url` / `result_url` / `video_url` 时会跳过 `/content` 下载,并优先保存到浏览器本地媒体存储。 -- 视频生成:任务创建或查询失败时会解析接口返回的 `msg` / `message` / 嵌套 JSON 错误,并在生成结果失败卡片里展示真实原因。 -- 画布生成配置:修复切换文本/视频/音频模式时模型选择仍显示为生图模型(gpt-image-2)的问题,切换模式后应自动匹配对应能力的默认模型。 diff --git a/web/src/components/layout/app-config-modal.tsx b/web/src/components/layout/app-config-modal.tsx index e147341..9d02330 100644 --- a/web/src/components/layout/app-config-modal.tsx +++ b/web/src/components/layout/app-config-modal.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { ModelPicker } from "@/components/model-picker"; import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer"; +import { ConfigPromptSources } from "@/components/layout/config-prompt-sources"; import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync"; import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync"; import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation"; @@ -240,6 +241,11 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" ), }, + { + key: "prompt-sources", + label: "提示词来源", + children: , + }, { key: "webdav", label: "WebDAV", diff --git a/web/src/components/layout/client-root-init.tsx b/web/src/components/layout/client-root-init.tsx index 139af77..21d3cc6 100644 --- a/web/src/components/layout/client-root-init.tsx +++ b/web/src/components/layout/client-root-init.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef } from "react"; import { App } from "antd"; import { createModelChannel, useConfigStore } from "@/stores/use-config-store"; +import { usePromptSourceScheduler } from "@/hooks/use-prompt-source-scheduler"; export function ClientRootInit({ children }: { children: ReactNode }) { const { message } = App.useApp(); @@ -11,6 +12,8 @@ export function ClientRootInit({ children }: { children: ReactNode }) { const config = useConfigStore((state) => state.config); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); + usePromptSourceScheduler(); + useEffect(() => { if (handledConfigParams.current) return; const searchParams = new URLSearchParams(window.location.search); diff --git a/web/src/components/layout/config-prompt-sources.tsx b/web/src/components/layout/config-prompt-sources.tsx new file mode 100644 index 0000000..66efc81 --- /dev/null +++ b/web/src/components/layout/config-prompt-sources.tsx @@ -0,0 +1,137 @@ +import { App, Button, Select, Switch } from "antd"; +import { useQueryClient } from "@tanstack/react-query"; +import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { useState } from "react"; + +import { PromptSourceEditorDrawer } from "./prompt-source-editor-drawer"; +import { PromptSourceContentModal } from "./prompt-source-content-modal"; +import { refreshAllSources, refreshSource } from "@/services/api/prompts"; +import { PROMPT_SOURCE_INTERVAL_OPTIONS, usePromptSourceStore } from "@/stores/use-prompt-source-store"; +import type { PromptSource } from "@/services/api/prompt-source-presets"; + +export function ConfigPromptSources() { + const { message } = App.useApp(); + const queryClient = useQueryClient(); + const sources = usePromptSourceStore((state) => state.sources); + const schedule = usePromptSourceStore((state) => state.schedule); + const addSource = usePromptSourceStore((state) => state.addSource); + const saveSource = usePromptSourceStore((state) => state.saveSource); + const removeSource = usePromptSourceStore((state) => state.removeSource); + const toggleSource = usePromptSourceStore((state) => state.toggleSource); + const updateSchedule = usePromptSourceStore((state) => state.updateSchedule); + + const [editingId, setEditingId] = useState(""); + const [viewingId, setViewingId] = useState(""); + const [refreshingId, setRefreshingId] = useState(""); + const [refreshingAll, setRefreshingAll] = useState(false); + + const editingSource = sources.find((item) => item.id === editingId) || null; + const viewingSource = sources.find((item) => item.id === viewingId) || null; + + const invalidatePrompts = () => queryClient.invalidateQueries({ queryKey: ["prompts"] }); + + const handleAdd = () => { + const source = addSource(); + setEditingId(source.id); + }; + + const handleSave = (source: PromptSource) => { + saveSource(source); + void invalidatePrompts(); + }; + + const handleDelete = (source: PromptSource) => { + if (sources.length <= 1) { + message.warning("至少保留一个来源"); + return; + } + removeSource(source.id); + void invalidatePrompts(); + }; + + const handleRefreshOne = async (source: PromptSource) => { + setRefreshingId(source.id); + try { + const count = await refreshSource(source.id); + await invalidatePrompts(); + message.success(`「${source.name}」已拉取 ${count} 条`); + } catch (error) { + message.error(error instanceof Error ? error.message : "拉取失败"); + } finally { + setRefreshingId(""); + } + }; + + const handleRefreshAll = async () => { + setRefreshingAll(true); + try { + const count = await refreshAllSources(); + updateSchedule("lastFetchedAt", new Date().toISOString()); + await invalidatePrompts(); + message.success(`全部来源已拉取,共 ${count} 条`); + } catch (error) { + message.error(error instanceof Error ? error.message : "拉取失败"); + } finally { + setRefreshingAll(false); + } + }; + + return ( +
+
+
每个来源是一段拉取脚本,可自定义、可查看内容;系统内置几个默认来源,你也可以本地新增。
+ +
+ +
+ {sources.map((source) => ( +
+
+ toggleSource(source.id, checked)} /> +
+
{source.name || "未命名来源"}
+
{source.githubUrl || "无 GitHub 地址"}
+
+
+
+ + + +
+
+ ))} +
+ +
+
定时拉取
+
+
+ 拉取周期 + patch({ name: event.target.value })} placeholder="用于分类展示" /> + + +
+ +
+
+
拉取脚本
+
脚本是一段异步函数体,直接使用下方变量,最后 return 一个提示词数组(每条至少含 title 和 prompt)。
+
+ +
+ +
+ +
+ patch({ script: value })} + height="100%" + theme={isDarkMode() ? "dark" : "light"} + extensions={[javascript()]} + placeholder={"// return 一个提示词数组;点击右上角「插入模板」查看示例。"} + style={{ height: "100%", fontSize: 13 }} + className="h-full [&_.cm-editor]:h-full [&_.cm-gutters]:border-none [&_.cm-scroller]:overflow-auto" + /> +
+
+ + ); +} diff --git a/web/src/hooks/use-prompt-source-scheduler.ts b/web/src/hooks/use-prompt-source-scheduler.ts new file mode 100644 index 0000000..0ddd705 --- /dev/null +++ b/web/src/hooks/use-prompt-source-scheduler.ts @@ -0,0 +1,37 @@ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { refreshAllSources } from "@/services/api/prompts"; +import { usePromptSourceStore } from "@/stores/use-prompt-source-store"; + +const CHECK_INTERVAL_MS = 60_000; + +/** Periodically refetch all enabled prompt sources while the app is open, based on the global schedule. */ +export function usePromptSourceScheduler() { + const queryClient = useQueryClient(); + const intervalMinutes = usePromptSourceStore((state) => state.schedule.intervalMinutes); + + useEffect(() => { + if (!intervalMinutes) return; + let running = false; + const tick = async () => { + if (running) return; + const { schedule, updateSchedule } = usePromptSourceStore.getState(); + const last = schedule.lastFetchedAt ? new Date(schedule.lastFetchedAt).getTime() : 0; + if (Date.now() - last < intervalMinutes * 60_000) return; + running = true; + try { + await refreshAllSources(); + updateSchedule("lastFetchedAt", new Date().toISOString()); + await queryClient.invalidateQueries({ queryKey: ["prompts"] }); + } catch { + // 拉取失败时静默重试,等待下一个检查周期。 + } finally { + running = false; + } + }; + void tick(); + const timer = window.setInterval(() => void tick(), CHECK_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [intervalMinutes, queryClient]); +} diff --git a/web/src/services/api/prompt-source-presets.ts b/web/src/services/api/prompt-source-presets.ts new file mode 100644 index 0000000..079de42 --- /dev/null +++ b/web/src/services/api/prompt-source-presets.ts @@ -0,0 +1,118 @@ +import { nanoid } from "nanoid"; + +export type PromptSource = { + id: string; + name: string; + githubUrl: string; + enabled: boolean; + script: string; +}; + +export function createPromptSource(source?: Partial): PromptSource { + return { + id: source?.id?.trim() || nanoid(), + name: source?.name?.trim() || "新来源", + githubUrl: source?.githubUrl?.trim() || "", + enabled: source?.enabled ?? true, + script: source?.script ?? "", + }; +} + +const awesomeGptImageScript = `// awesome-gpt-image:从 README.zh-CN.md 解析,## 为标签分组,### 为单条提示词。 +const base = "https://raw.githubusercontent.com/ZeroLu/awesome-gpt-image/main"; +const markdown = await fetchText(\`\${base}/README.zh-CN.md\`); +const items = []; +for (const section of splitSections(markdown, "## ")) { + const tags = tagsFromHeading(firstMatch(section, /^##\\s+(.+)$/m)); + for (const block of splitSections(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 = extractImages(base, block); + items.push(makePrompt({ id: \`awesome-gpt-image-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags, preview: markdownPreview(images) })); + } +} +return items;`; + +const awesomeGpt4oImageScript = `// Awesome-GPT4o-Image-Prompts:README.zh-CN.md 里每个 ### 段落一条提示词。 +const base = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main"; +const markdown = await fetchText(\`\${base}/README.zh-CN.md\`); +const items = []; +for (const block of splitSections(markdown, "### ")) { + const title = firstMatch(block, /^###\\s+(.+)$/m).trim(); + const prompt = firstMatch(block, /- \\*\\*提示词文本:\\*\\*\\s*\\\`(.*?)\\\`/s).trim(); + if (!title || !prompt) continue; + const images = extractImages(base, block); + items.push(makePrompt({ id: \`awesome-gpt4o-image-prompts-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags: ["gpt4o"], preview: markdownPreview(images) })); +} +return items;`; + +function youMindScript(base: string, idPrefix: string, modelTag: string) { + return `// YouMind 系列:README_zh.md 里 "### No.N: 标题" + "#### ...提示词" 代码块。 +const base = "${base}"; +const idPrefix = "${idPrefix}"; +const modelTag = "${modelTag}"; +const markdown = await fetchText(\`\${base}/README_zh.md\`); +const items = []; +for (const block of splitSections(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 = extractImages(base, block); + const [, prefix] = title.match(/^(.+?) - /) || []; + const tags = [modelTag, ...tagsFromHeading(prefix || "")]; + items.push(makePrompt({ id: \`\${idPrefix}-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags, preview: markdownPreview(images) })); +} +return items;`; +} + +const davidWuGptImage2Script = `// davidwu:prompts.json 结构化数据,逐条转换。 +const base = "https://raw.githubusercontent.com/davidwuw0811-boop/awesome-gpt-image2-prompts/main"; +const data = await fetchJson(\`\${base}/prompts.json\`); +const items = []; +data.forEach((item, index) => { + const title = (item.title_cn || item.title_en || "").trim(); + const prompt = (item.prompt || "").trim(); + if (!title || !prompt) return; + const image = absoluteUrl(base, item.image || ""); + const tags = splitTags([item.category_cn, item.category, item.author, item.source].filter(Boolean).join("/"), /\\//); + if (item.needs_ref) tags.push("需要参考图"); + const preview = [item.title_en, item.note, image ? \`![](\${image})\` : ""].filter(Boolean).join("\\n\\n"); + items.push(makePrompt({ id: \`davidwu-gpt-image2-prompts-\${leftPad(item.id || index + 1)}\`, title, prompt, coverUrl: image, tags, preview })); +}); +return items;`; + +export const DEFAULT_PROMPT_SOURCES: PromptSource[] = [ + { id: "awesome-gpt-image", name: "awesome-gpt-image", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", enabled: true, script: awesomeGptImageScript }, + { id: "awesome-gpt4o-image-prompts", name: "awesome-gpt4o-image-prompts", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", enabled: true, script: awesomeGpt4oImageScript }, + { + id: "youmind-gpt-image-2", + name: "youmind-gpt-image-2", + githubUrl: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", + enabled: true, + script: youMindScript("https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main", "youmind-gpt-image-2", "gpt-image-2"), + }, + { + id: "youmind-nano-banana-pro", + name: "youmind-nano-banana-pro", + githubUrl: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", + enabled: true, + script: youMindScript("https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main", "youmind-nano-banana-pro", "nano-banana-pro"), + }, + { id: "davidwu-gpt-image2-prompts", name: "davidwu-gpt-image2-prompts", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", enabled: true, script: davidWuGptImage2Script }, +]; + +/** Starter script inserted when a user creates a blank source. */ +export const PROMPT_SOURCE_TEMPLATE = `// 拉取远程列表并 return 一个提示词数组;每条至少含 title 和 prompt。 +// 可用辅助见右侧「可用变量」,例如 fetchText / splitSections / makePrompt。 +const base = "https://raw.githubusercontent.com/owner/repo/main"; +const markdown = await fetchText(\`\${base}/README.md\`); +const items = []; +for (const block of splitSections(markdown, "### ")) { + const title = firstMatch(block, /^###\\s+(.+)$/m).trim(); + const prompt = firstMatch(block, /\\\`\\\`\\\`[\\w-]*\\r?\\n(.*?)\\r?\\n\\\`\\\`\\\`/s).trim(); + if (!title || !prompt) continue; + const images = extractImages(base, block); + items.push(makePrompt({ id: \`my-source-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags: [], preview: markdownPreview(images) })); +} +return items;`; diff --git a/web/src/services/api/prompt-source-runtime.ts b/web/src/services/api/prompt-source-runtime.ts new file mode 100644 index 0000000..0165166 --- /dev/null +++ b/web/src/services/api/prompt-source-runtime.ts @@ -0,0 +1,165 @@ +/** + * Runtime for user-authored prompt-source scripts. A script is an async function body that fetches + * a remote list (markdown / json) and `return`s an array of prompt items. It runs with a set of flat + * helper locals (see PROMPT_SOURCE_VARIABLES) so scripts stay short and declarative. + */ + +export type RawPrompt = { + id: string; + title: string; + coverUrl: string; + prompt: string; + tags: string[]; + preview: string; + createdAt: string; + updatedAt: string; +}; + +type RunOptions = { signal?: AbortSignal }; + +async function fetchText(url: string) { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`${url} 拉取失败`); + return response.text(); +} + +async function fetchJson(url: string) { + return JSON.parse(await fetchText(url)) as T; +} + +/** Split markdown into blocks, each starting at a line that begins with `prefix` (e.g. "## " / "### "). */ +function splitSections(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 absoluteUrl(baseUrl: string, path: string) { + if (!path) return ""; + if (/^https?:\/\//i.test(path)) return path; + return `${baseUrl}/${path.replace(/^\.?\//, "")}`; +} + +function extractImages(baseUrl: string, markdown: string) { + return Array.from(markdown.matchAll(/!\[[^\]]*]\(([^)]+)\)/g), (match) => absoluteUrl(baseUrl, match[1])).filter(Boolean); +} + +function splitTags(value: string, pattern: RegExp) { + return value + .split(pattern) + .map((tag) => tag.trim().toLowerCase()) + .filter(Boolean); +} + +function tagsFromHeading(heading: string) { + return splitTags(heading.replace(/[^\p{L}\p{N}/&、与 ]/gu, ""), /\s*(?:\/|&|、|与)\s*/); +} + +function markdownPreview(images: string[]) { + return images + .filter(Boolean) + .map((image) => `![](${image})`) + .join("\n\n"); +} + +function leftPad(value: number) { + return String(value).padStart(4, "0"); +} + +function makePrompt(input: { id: string; title: string; prompt: string; coverUrl?: string; tags?: string[]; preview?: string; createdAt?: string; updatedAt?: string }): RawPrompt { + return { + id: input.id, + title: input.title, + prompt: input.prompt, + coverUrl: input.coverUrl || "", + tags: input.tags || [], + preview: input.preview || "", + createdAt: input.createdAt || "", + updatedAt: input.updatedAt || "", + }; +} + +/** Run a prompt-source script and normalize its result into a deduped RawPrompt[]. */ +export async function runPromptSource(script: string, options?: RunOptions): Promise { + const body = script.trim(); + if (!body) throw new Error("提示词来源脚本为空"); + const runner = new Function( + "fetchText", + "fetchJson", + "splitSections", + "firstMatch", + "extractImages", + "absoluteUrl", + "tagsFromHeading", + "splitTags", + "markdownPreview", + "leftPad", + "makePrompt", + "signal", + `"use strict"; return (async () => {\n${body}\n})();`, + ) as (...args: unknown[]) => Promise; + let result: unknown; + try { + result = await runner(fetchText, fetchJson, splitSections, firstMatch, extractImages, absoluteUrl, tagsFromHeading, splitTags, markdownPreview, leftPad, makePrompt, options?.signal); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + const message = error instanceof Error ? error.message : String(error); + throw new Error(`提示词来源脚本执行失败:${message}`); + } + if (!Array.isArray(result)) throw new Error("提示词来源脚本需要 return 一个数组"); + const seen = new Set(); + const items: RawPrompt[] = []; + for (const raw of result) { + if (!raw || typeof raw !== "object") continue; + const record = raw as Record; + const title = String(record.title || "").trim(); + const prompt = String(record.prompt || "").trim(); + if (!title || !prompt) continue; + const id = String(record.id || "").trim() || `prompt-${leftPad(items.length + 1)}`; + if (seen.has(id)) continue; + seen.add(id); + items.push( + makePrompt({ + id, + title, + prompt, + coverUrl: String(record.coverUrl || ""), + tags: Array.isArray(record.tags) ? record.tags.map((tag) => String(tag)).filter(Boolean) : [], + preview: String(record.preview || ""), + createdAt: String(record.createdAt || ""), + updatedAt: String(record.updatedAt || ""), + }), + ); + } + return items; +} + +export type PromptSourceVariable = { name: string; type: string; desc: string }; + +/** Documentation surface shown in the prompt-source script editor. */ +export const PROMPT_SOURCE_VARIABLES: PromptSourceVariable[] = [ + { name: "fetchText", type: "function", desc: "fetchText(url) 拉取纯文本(README 等),失败抛错" }, + { name: "fetchJson", type: "function", desc: "fetchJson(url) 拉取并解析 JSON" }, + { name: "splitSections", type: "function", desc: "splitSections(markdown, prefix) 按标题前缀(如 '### ')切分成段落数组" }, + { name: "firstMatch", type: "function", desc: "firstMatch(text, /正则/) 返回第一个捕获组,未匹配返回空串" }, + { name: "extractImages", type: "function", desc: "extractImages(baseUrl, markdown) 提取 markdown 图片并补全为绝对地址" }, + { name: "absoluteUrl", type: "function", desc: "absoluteUrl(baseUrl, path) 把相对路径拼成绝对 URL" }, + { name: "tagsFromHeading", type: "function", desc: "tagsFromHeading(heading) 从标题按 / & 、与 切出标签(小写去重前)" }, + { name: "splitTags", type: "function", desc: "splitTags(value, /分隔符/) 切分标签并转小写去空" }, + { name: "markdownPreview", type: "function", desc: "markdownPreview(images) 把图片数组拼成 markdown 预览文本" }, + { name: "leftPad", type: "function", desc: "leftPad(n) 数字左补零到 4 位,用于生成有序 id" }, + { name: "makePrompt", type: "function", desc: "makePrompt({id,title,prompt,coverUrl,tags,preview}) 构造一条提示词;title 和 prompt 必填" }, + { name: "signal", type: "AbortSignal", desc: "取消信号,可透传给需要的请求" }, +]; diff --git a/web/src/services/api/prompts.ts b/web/src/services/api/prompts.ts index 291aeb0..6473051 100644 --- a/web/src/services/api/prompts.ts +++ b/web/src/services/api/prompts.ts @@ -1,22 +1,12 @@ import localforage from "localforage"; -export type Prompt = { - id: string; - title: string; - coverUrl: string; - prompt: string; - tags: string[]; - category: string; - githubUrl: string; - preview: string; - createdAt: string; - updatedAt: string; -}; +import { runPromptSource, type RawPrompt } from "./prompt-source-runtime"; +import { usePromptSourceStore } from "@/stores/use-prompt-source-store"; +import type { PromptSource } from "./prompt-source-presets"; -type PromptCategory = { +export type Prompt = RawPrompt & { category: string; githubUrl: string; - build: () => Promise[]>; }; export const ALL_PROMPTS_OPTION = "全部"; @@ -28,27 +18,70 @@ export type PromptListResponse = { total: number; }; -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 cacheTtlMs = 1000 * 60 * 60; -const promptCacheKey = "third-party-prompts"; const promptCacheStore = localforage.createInstance({ name: "infinite-canvas", storeName: "prompt_cache" }); -const categories: PromptCategory[] = [ - { 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 }, -]; +type SourceCache = { items: Prompt[]; fetchedAt: number; signature: string }; -let loadingPrompts: Promise | null = null; +const loadingSources = new Map>(); + +function enabledSources() { + return usePromptSourceStore.getState().sources.filter((source) => source.enabled); +} + +function cacheKey(sourceId: string) { + return `prompt-source:${sourceId}`; +} + +/** Cheap stable signature of a source so cached prompts invalidate when the script or name changes. */ +function sourceSignature(source: PromptSource) { + const value = `${source.name}\n${source.githubUrl}\n${source.script}`; + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return `${value.length}:${hash}`; +} + +function withSourceMeta(source: PromptSource, items: RawPrompt[]): Prompt[] { + return items.map((item) => ({ ...item, category: source.name, githubUrl: source.githubUrl })); +} + +async function runSource(source: PromptSource): Promise { + const items = await runPromptSource(source.script); + const prompts = withSourceMeta(source, items); + await promptCacheStore.setItem(cacheKey(source.id), { items: prompts, fetchedAt: Date.now(), signature: sourceSignature(source) }); + return prompts; +} + +async function getSourcePrompts(source: PromptSource, force = false): Promise { + const signature = sourceSignature(source); + if (!force) { + const cached = await promptCacheStore.getItem(cacheKey(source.id)); + if (cached?.items?.length && cached.signature === signature && Date.now() - cached.fetchedAt < cacheTtlMs) return cached.items; + } + if (!force && loadingSources.has(source.id)) return loadingSources.get(source.id)!; + const loading = runSource(source).finally(() => loadingSources.delete(source.id)); + loadingSources.set(source.id, loading); + return loading; +} + +/** Aggregate prompts across all enabled sources; a failing source is skipped so others still load. */ +async function getAllPrompts(): Promise { + const settled = await Promise.all( + enabledSources().map(async (source) => { + try { + return await getSourcePrompts(source); + } catch { + return []; + } + }), + ); + return settled.flat(); +} 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 items = await getAllPrompts(); const normalizedKeyword = keyword.trim().toLowerCase(); const normalizedPage = Math.max(1, page); const normalizedPageSize = Math.max(1, Math.min(100, pageSize)); @@ -58,35 +91,36 @@ export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROM return { items: filtered.slice((normalizedPage - 1) * normalizedPageSize, normalizedPage * normalizedPageSize), tags: collectTags(withoutTagFilter), - categories: categories.map((item) => item.category), + categories: enabledSources().map((source) => source.name), 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; +/** Load a single source's prompts (used by the source content table). Throws so the caller can show the error. */ +export async function fetchSourcePrompts(sourceId: string, force = false): Promise { + const source = usePromptSourceStore.getState().sources.find((item) => item.id === sourceId); + if (!source) throw new Error("提示词来源不存在"); + return getSourcePrompts(source, force); } -async function loadPrompts() { +/** Force refetch one source and refresh its cache; returns the fetched count. */ +export async function refreshSource(sourceId: string): Promise { + const items = await fetchSourcePrompts(sourceId, true); + return items.length; +} + +/** Force refetch every enabled source; returns the total prompt count. */ +export async function refreshAllSources(): Promise { const settled = await Promise.all( - categories.map(async (category) => { + enabledSources().map(async (source) => { try { - const items = await category.build(); - return items.map((item) => ({ ...item, category: category.category, githubUrl: category.githubUrl })); + return await getSourcePrompts(source, true); } catch { return []; } }), ); - const items = settled.flat(); - await promptCacheStore.setItem(promptCacheKey, { items, fetchedAt: Date.now() }); - return items; + return settled.reduce((total, items) => total + items.length, 0); } function filterPrompts(items: Prompt[], options: { keyword: string; category: string; tags: string[] }) { @@ -98,142 +132,10 @@ function filterPrompts(items: Prompt[], options: { keyword: string; category: st }); } -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/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index ffe2864..1376f1f 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -56,7 +56,7 @@ export type WebdavSyncConfig = { directory: string; lastSyncedAt: string; }; -export type ConfigTabKey = "channels" | "preferences" | "webdav"; +export type ConfigTabKey = "channels" | "preferences" | "prompt-sources" | "webdav"; export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store"; const CHANNEL_MODEL_SEPARATOR = "::"; diff --git a/web/src/stores/use-prompt-source-store.ts b/web/src/stores/use-prompt-source-store.ts new file mode 100644 index 0000000..fabc6d4 --- /dev/null +++ b/web/src/stores/use-prompt-source-store.ts @@ -0,0 +1,65 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +import { DEFAULT_PROMPT_SOURCES, createPromptSource, type PromptSource } from "@/services/api/prompt-source-presets"; + +export type PromptSourceSchedule = { + intervalMinutes: number; + lastFetchedAt: string; +}; + +const PROMPT_SOURCE_STORE_KEY = "infinite-canvas:prompt_source_store"; + +const defaultSchedule: PromptSourceSchedule = { + intervalMinutes: 30, + lastFetchedAt: "", +}; + +export const PROMPT_SOURCE_INTERVAL_OPTIONS = [ + { label: "关闭定时", value: 0 }, + { label: "每 30 分钟", value: 30 }, + { label: "每 1 小时", value: 60 }, + { label: "每 6 小时", value: 360 }, + { label: "每 24 小时", value: 1440 }, +]; + +type PromptSourceStore = { + sources: PromptSource[]; + schedule: PromptSourceSchedule; + addSource: () => PromptSource; + saveSource: (source: PromptSource) => void; + removeSource: (id: string) => void; + toggleSource: (id: string, enabled: boolean) => void; + updateSchedule: (key: K, value: PromptSourceSchedule[K]) => void; +}; + +export const usePromptSourceStore = create()( + persist( + (set) => ({ + sources: DEFAULT_PROMPT_SOURCES, + schedule: defaultSchedule, + addSource: () => { + const source = createPromptSource(); + set((state) => ({ sources: [...state.sources, source] })); + return source; + }, + saveSource: (source) => set((state) => ({ sources: state.sources.map((item) => (item.id === source.id ? source : item)) })), + removeSource: (id) => set((state) => ({ sources: state.sources.filter((item) => item.id !== id) })), + toggleSource: (id, enabled) => set((state) => ({ sources: state.sources.map((item) => (item.id === id ? { ...item, enabled } : item)) })), + updateSchedule: (key, value) => set((state) => ({ schedule: { ...state.schedule, [key]: value } })), + }), + { + name: PROMPT_SOURCE_STORE_KEY, + partialize: (state) => ({ sources: state.sources, schedule: state.schedule }), + merge: (persisted, current) => { + const persistedState = (persisted || {}) as Partial; + const sources = Array.isArray(persistedState.sources) && persistedState.sources.length ? persistedState.sources.map((item) => createPromptSource(item)) : DEFAULT_PROMPT_SOURCES; + return { + ...current, + sources, + schedule: { ...defaultSchedule, ...(persistedState.schedule || {}) }, + }; + }, + }, + ), +);