feat(prompt-sources): add custom script fetching functionality for prompt sources

This commit is contained in:
HouYunFei
2026-07-17 17:36:42 +08:00
parent 0e1edd1484
commit d4130bbb79
14 changed files with 851 additions and 229 deletions
+2
View File
@@ -2,6 +2,8 @@
## Unreleased
+ [新增] 提示词来源增加自定义脚本抓取功能、支持新增来源。
## v0.9.0 - 2026-07-17
+ [调整] 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现,画布更简洁。
@@ -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 | 已实现同步逻辑 |
@@ -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/<name>/` 独立目录(各自 package.json + esbuild + src + README,互不耦合),`npm run build` 产物落到 `dist/<name>.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 自动尝试重连;需验证已连接、连接中、连接失败、断开和刷新自动重连状态。
- 画布 AgentCodex 旧 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)的问题,切换模式后应自动匹配对应能力的默认模型。
@@ -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"
</Form>
),
},
{
key: "prompt-sources",
label: "提示词来源",
children: <ConfigPromptSources />,
},
{
key: "webdav",
label: "WebDAV",
@@ -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);
@@ -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 (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-stone-500"></div>
<Button type="primary" icon={<Plus className="size-4" />} onClick={handleAdd}>
</Button>
</div>
<div className="space-y-2">
{sources.map((source) => (
<div key={source.id} className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
<div className="flex min-w-0 items-center gap-3">
<Switch size="small" checked={source.enabled} onChange={(checked) => toggleSource(source.id, checked)} />
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{source.name || "未命名来源"}</div>
<div className="mt-1 truncate text-xs text-stone-500">{source.githubUrl || "无 GitHub 地址"}</div>
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button size="small" icon={<Eye className="size-3.5" />} onClick={() => setViewingId(source.id)}>
</Button>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={refreshingId === source.id} onClick={() => void handleRefreshOne(source)}>
</Button>
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingId(source.id)}>
</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(source)} />
</div>
</div>
))}
</div>
<section className="mt-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="mb-3 text-sm font-semibold"></div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-stone-500"></span>
<Select size="small" className="w-36" value={schedule.intervalMinutes} options={PROMPT_SOURCE_INTERVAL_OPTIONS} onChange={(value) => updateSchedule("intervalMinutes", value)} />
</div>
<Button size="small" type="primary" icon={<RefreshCw className="size-3.5" />} loading={refreshingAll} onClick={() => void handleRefreshAll()}>
</Button>
<span className="text-xs text-stone-500">{schedule.lastFetchedAt ? `上次拉取 ${formatTime(schedule.lastFetchedAt)}` : "尚未定时拉取"}</span>
</div>
<div className="mt-2 text-xs text-stone-400"></div>
</section>
<PromptSourceEditorDrawer open={Boolean(editingSource)} source={editingSource} onSave={handleSave} onClose={() => setEditingId("")} />
<PromptSourceContentModal source={viewingSource} onClose={() => setViewingId("")} />
</div>
);
}
function formatTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
@@ -0,0 +1,130 @@
import { App, Button, Empty, Modal, Space, Table, Tag } from "antd";
import { Copy, FolderPlus, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog";
import { useCopyText } from "@/hooks/use-copy-text";
import { useAssetStore } from "@/stores/use-asset-store";
import { fetchSourcePrompts, refreshSource, type Prompt } from "@/services/api/prompts";
import type { PromptSource } from "@/services/api/prompt-source-presets";
export function PromptSourceContentModal({ source, onClose }: { source: PromptSource | null; onClose: () => void }) {
const { message } = App.useApp();
const [items, setItems] = useState<Prompt[]>([]);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<Prompt | null>(null);
const copyText = useCopyText();
const addAsset = useAssetStore((state) => state.addAsset);
const load = useCallback(
async (force: boolean) => {
if (!source) return;
setLoading(true);
try {
setItems(force ? await refreshSourceItems(source.id) : await fetchSourcePrompts(source.id));
} catch (error) {
message.error(error instanceof Error ? error.message : "拉取提示词失败");
} finally {
setLoading(false);
}
},
[source, message],
);
useEffect(() => {
if (source) void load(false);
else setItems([]);
}, [source, load]);
const saveAsset = (item: Prompt) => {
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
message.success("已加入我的资产");
};
return (
<>
<Modal
open={Boolean(source)}
onCancel={onClose}
width={980}
footer={null}
title={
<div className="flex flex-wrap items-center justify-between gap-2 pr-6">
<div>
<div className="text-base font-semibold">{source?.name || ""} · </div>
<div className="mt-0.5 text-xs font-normal text-stone-500"> {items.length} </div>
</div>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void load(true)}>
</Button>
</div>
}
>
<Table<Prompt>
rowKey="id"
size="small"
loading={loading}
dataSource={items}
pagination={{ pageSize: 10, showSizeChanger: false, size: "small" }}
scroll={{ y: "56vh" }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" /> }}
columns={[
{
title: "封面",
dataIndex: "coverUrl",
width: 72,
render: (coverUrl: string) => (coverUrl ? <img src={coverUrl} alt="" className="size-12 rounded object-cover" /> : <div className="size-12 rounded bg-stone-100 dark:bg-stone-800" />),
},
{
title: "标题",
dataIndex: "title",
render: (title: string, item) => (
<div className="min-w-0">
<div className="truncate font-medium">{title}</div>
<div className="mt-0.5 line-clamp-2 text-xs text-stone-500">{item.prompt}</div>
</div>
),
},
{
title: "标签",
dataIndex: "tags",
width: 200,
render: (tags: string[]) => (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 4).map((tag) => (
<Tag key={tag} className="m-0">
{tag}
</Tag>
))}
</div>
),
},
{
title: "操作",
width: 210,
render: (_, item) => (
<Space size={4} wrap>
<Button size="small" type="text" icon={<Copy className="size-3.5" />} onClick={() => copyText(item.prompt, "提示词已复制")}>
</Button>
<Button size="small" type="text" onClick={() => setDetail(item)}>
</Button>
<Button size="small" type="text" icon={<FolderPlus className="size-3.5" />} onClick={() => saveAsset(item)}>
</Button>
</Space>
),
},
]}
/>
</Modal>
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={saveAsset} />
</>
);
}
async function refreshSourceItems(sourceId: string) {
await refreshSource(sourceId);
return fetchSourcePrompts(sourceId);
}
@@ -0,0 +1,108 @@
import { javascript } from "@codemirror/lang-javascript";
import CodeMirror from "@uiw/react-codemirror";
import { Button, Drawer, Input, Space } from "antd";
import { useEffect, useState } from "react";
import { PROMPT_SOURCE_VARIABLES } from "@/services/api/prompt-source-runtime";
import { PROMPT_SOURCE_TEMPLATE, type PromptSource } from "@/services/api/prompt-source-presets";
function isDarkMode() {
return typeof document !== "undefined" && document.documentElement.classList.contains("dark");
}
export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { open: boolean; source: PromptSource | null; onSave: (source: PromptSource) => void; onClose: () => void }) {
const [draft, setDraft] = useState<PromptSource | null>(source);
useEffect(() => {
if (open && source) setDraft(source);
}, [open, source]);
if (!draft) return null;
const patch = (value: Partial<PromptSource>) => setDraft((current) => (current ? { ...current, ...value } : current));
const save = () => {
onSave({ ...draft, name: draft.name.trim() || "未命名来源", githubUrl: draft.githubUrl.trim(), script: draft.script.trim() });
onClose();
};
return (
<Drawer
open={open}
width={880}
title="编辑提示词来源"
onClose={onClose}
styles={{ body: { paddingTop: 16 } }}
extra={
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" onClick={save}>
</Button>
</Space>
}
>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm font-medium"></span>
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} placeholder="用于分类展示" />
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium">GitHub </span>
<Input value={draft.githubUrl} onChange={(event) => patch({ githubUrl: event.target.value })} placeholder="https://github.com/owner/repo" />
</label>
</div>
<div className="mt-6 mb-2 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold"></div>
<div className="mt-0.5 text-xs text-stone-500">使 return title prompt</div>
</div>
<Button size="small" onClick={() => patch({ script: PROMPT_SOURCE_TEMPLATE })}>
</Button>
</div>
<div className="flex h-[56vh] min-h-[420px] overflow-hidden rounded-lg border border-stone-200 dark:border-stone-800">
<aside className="flex w-[300px] shrink-0 flex-col overflow-y-auto border-r border-stone-200 bg-stone-50/80 dark:border-stone-800 dark:bg-stone-900/40">
<div className="px-4 py-3">
<div className="mb-2.5 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wide text-stone-400"></span>
<span className="text-[10px] text-stone-400"></span>
</div>
<div className="space-y-1.5">
{PROMPT_SOURCE_VARIABLES.map((variable) => (
<button
key={variable.name}
type="button"
onClick={() => patch({ script: draft.script ? `${draft.script}\n${variable.name}` : variable.name })}
className="group block w-full rounded-lg border border-transparent px-2.5 py-2 text-left transition-colors hover:border-stone-200 hover:bg-white dark:hover:border-stone-700 dark:hover:bg-stone-800/60"
>
<div className="flex flex-wrap items-baseline gap-1.5">
<code className="rounded bg-stone-200/80 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-stone-800 group-hover:bg-blue-100 group-hover:text-blue-700 dark:bg-stone-800 dark:text-stone-100 dark:group-hover:bg-blue-950 dark:group-hover:text-blue-300">
{variable.name}
</code>
<span className="font-mono text-[10px] text-stone-400">{variable.type}</span>
</div>
<div className="mt-1 text-xs leading-5 text-stone-500 dark:text-stone-400">{variable.desc}</div>
</button>
))}
</div>
</div>
</aside>
<div className="min-w-0 flex-1 overflow-hidden bg-white dark:bg-stone-950">
<CodeMirror
value={draft.script}
onChange={(value) => 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"
/>
</div>
</div>
</Drawer>
);
}
@@ -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]);
}
@@ -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>): 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-PromptsREADME.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 = `// davidwuprompts.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;`;
@@ -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<T = unknown>(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<RawPrompt[]> {
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<unknown>;
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<string>();
const items: RawPrompt[] = [];
for (const raw of result) {
if (!raw || typeof raw !== "object") continue;
const record = raw as Record<string, unknown>;
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: "取消信号,可透传给需要的请求" },
];
+79 -177
View File
@@ -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<Omit<Prompt, "category" | "githubUrl">[]>;
};
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<Prompt[]> | null = null;
const loadingSources = new Map<string, Promise<Prompt[]>>();
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<Prompt[]> {
const items = await runPromptSource(source.script);
const prompts = withSourceMeta(source, items);
await promptCacheStore.setItem<SourceCache>(cacheKey(source.id), { items: prompts, fetchedAt: Date.now(), signature: sourceSignature(source) });
return prompts;
}
async function getSourcePrompts(source: PromptSource, force = false): Promise<Prompt[]> {
const signature = sourceSignature(source);
if (!force) {
const cached = await promptCacheStore.getItem<SourceCache>(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<Prompt[]> {
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<Prompt[]> {
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<number> {
const items = await fetchSourcePrompts(sourceId, true);
return items.length;
}
/** Force refetch every enabled source; returns the total prompt count. */
export async function refreshAllSources(): Promise<number> {
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<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";
}
+1 -1
View File
@@ -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 = "::";
+65
View File
@@ -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: <K extends keyof PromptSourceSchedule>(key: K, value: PromptSourceSchedule[K]) => void;
};
export const usePromptSourceStore = create<PromptSourceStore>()(
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<PromptSourceStore>;
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 || {}) },
};
},
},
),
);