mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 06:54:06 +08:00
feat(prompts): unify sources and add personal library
This commit is contained in:
+10
-10
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
+ [新增] 提示词来源新增 Banana Prompt Quicker,并支持添加自定义标准 JSON 来源。
|
||||||
|
+ [新增] 提示词中心新增「我的提示词」,支持本地管理、从公共库保存,并可在画布提示词库中直接使用。
|
||||||
+ [新增] Agent 新增统一生成任务状态查询,支持查看画布、生图工作台和视频工作台任务进度。
|
+ [新增] Agent 新增统一生成任务状态查询,支持查看画布、生图工作台和视频工作台任务进度。
|
||||||
+ [新增] 左侧画布面板新增「提示词库」Tab,按来源折叠分组。
|
+ [调整] 内置提示词来源改为读取 Image Prompts 统一 JSON 数据,不再由画布端分别解析。
|
||||||
+ [新增] 提示词来源增加自定义脚本抓取功能、支持新增来源。
|
+ [调整] 提示词来源配置保留卡片式交互,并展示来源数量、同步状态和上次成功时间。
|
||||||
+ [优化] Agent 对话通过右侧主题气泡与左侧开放式回复布局区分用户和 AI 消息。
|
+ [优化] 提示词来源支持独立缓存和更新,更新失败时保留上一次成功内容。
|
||||||
+ [优化] 画布节点生成完成后,输入框保留提示词内容,不再自动清空。
|
+ [优化] Agent 对话区分用户与 AI 消息,并优化会话切换和回到最新消息的交互。
|
||||||
+ [修复] 本地 Agent 请求与发起标签页强绑定,校验工具结果归属,并在页面关闭后回退到最近聚焦页面。
|
+ [优化] 画布生成完成后保留提示词输入,并改善提示词库和资产面板的交互性能。
|
||||||
+ [修复] 多标签页共享 Codex 会话改由 Agent 广播同步,按线程过滤消息和状态,并禁止运行中切换会话。
|
+ [修复] 本地 Agent 完善多标签页请求隔离、结果归属、焦点回退和 Codex 会话状态同步。
|
||||||
+ [修复] 本地 Agent 统一广播 Codex 运行状态,新连接标签页会同步禁用发送,并明确区分工具完成与本轮完成。
|
+ [修复] 本地 Agent 上传的图片附件可正确创建画布图片节点并连接生成流程。
|
||||||
+ [修复] 本地 Agent 上传的图片附件可创建为画布图片节点并连接生成流程,不再出现参考图空节点。
|
+ [修复] 画布提示词库支持跨来源搜索,插入节点时保留提示词标题。
|
||||||
+ [修复] Agent 对话切换时自动定位到最新消息,并在向上浏览记录时提供快速回到底部按钮。
|
|
||||||
+ [修复] 处于「提示词库/资产」Tab 时拖动画布元素卡顿,面板内容不再随节点更新重渲染。
|
|
||||||
|
|
||||||
## v0.9.0 - 2026-07-17
|
## v0.9.0 - 2026-07-17
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ description: 当前版本已实现但仍需人工验证的变更项
|
|||||||
|
|
||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
|
- 提示词来源:6 个内置来源应从 Image Prompts 统一仓库读取,更新后数量依次为 323、494、53、76、126、129;提示词仍按 6 个来源分组并可独立启用,来源内可继续按 `tags` 筛选。添加标准 JSON URL 后应能查看内容,填写非数组 JSON 或不可访问地址时应显示失败,并继续保留该来源上一次成功缓存的内容。
|
||||||
|
- 提示词来源界面:来源应以卡片列表展示,启用开关位于左侧,数量、同步状态和上次成功时间作为次级信息显示,查看、拉取及自定义来源编辑/删除操作使用带文字按钮;底部定时拉取区域应保持独立边框布局。
|
||||||
|
- 我的提示词:在提示词中心切换到「我的提示词」,验证新增、编辑、删除和搜索;从提示词库保存后应出现在个人列表、画布左侧提示词面板和提示词选择弹窗中,刷新页面后数据仍保留在本地,Agent 搜索提示词时也应能查到。
|
||||||
|
- 画布提示词库:不展开任何公共来源直接搜索其中的提示词,匹配项应自动显示;点击「插入画布」后应创建正文正确且标题保持为提示词标题的文本节点。
|
||||||
- 全站 Agent:新增 `generation_get_status` 工具,画布生成节点可按 `nodeIds` 查询,生图和视频工作台提交后会返回 `taskId` 并可查询排队、运行、成功或失败状态;需验证查询只由当前活动标签页返回。
|
- 全站 Agent:新增 `generation_get_status` 工具,画布生成节点可按 `nodeIds` 查询,生图和视频工作台提交后会返回 `taskId` 并可查询排队、运行、成功或失败状态;需验证查询只由当前活动标签页返回。
|
||||||
- 本地 Agent 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;网页面板发起的整个 Codex turn 应固定操作发起页面,即使中途聚焦另一标签页也不能切换目标;关闭当前页面后应回退到最近聚焦且仍连接的页面,其他页面回传同一请求结果应被拒绝。
|
- 本地 Agent 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;网页面板发起的整个 Codex turn 应固定操作发起页面,即使中途聚焦另一标签页也不能切换目标;关闭当前页面后应回退到最近聚焦且仍连接的页面,其他页面回传同一请求结果应被拒绝。
|
||||||
- 本地 Agent 多标签页会话同步:所有标签页共享同一个站点级 Codex 活跃线程;任一页面发送消息、新建、恢复或删除会话后,其他页面应同步活跃线程和聊天记录;Agent 输出仅显示在事件所属线程,运行中不能新建、恢复、删除或再次发送任务。
|
- 本地 Agent 多标签页会话同步:所有标签页共享同一个站点级 Codex 活跃线程;任一页面发送消息、新建、恢复或删除会话后,其他页面应同步活跃线程和聊天记录;Agent 输出仅显示在事件所属线程,运行中不能新建、恢复、删除或再次发送任务。
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import { exportCanvasNodes } from "@/lib/canvas/canvas-export";
|
|||||||
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
import { getNodeDefinition } from "@/lib/canvas/node-registry";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog";
|
import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog";
|
||||||
import { fetchSourcePrompts, type Prompt } from "@/services/api/prompts";
|
import { fetchSourcePrompts, personalPromptToPrompt, type Prompt } from "@/services/api/prompts";
|
||||||
import { uploadMediaFile } from "@/services/file-storage";
|
import { uploadMediaFile } from "@/services/file-storage";
|
||||||
import { uploadImage } from "@/services/image-storage";
|
import { uploadImage } from "@/services/image-storage";
|
||||||
import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store";
|
import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store";
|
||||||
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
|
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
|
||||||
|
import { usePromptStore } from "@/stores/use-prompt-store";
|
||||||
import { CANVAS_SIDE_PANEL_MAX_WIDTH, CANVAS_SIDE_PANEL_MIN_WIDTH, CANVAS_SIDE_PANEL_MOTION_MS, useCanvasSidePanelStore } from "@/stores/use-canvas-side-panel-store";
|
import { CANVAS_SIDE_PANEL_MAX_WIDTH, CANVAS_SIDE_PANEL_MIN_WIDTH, CANVAS_SIDE_PANEL_MOTION_MS, useCanvasSidePanelStore } from "@/stores/use-canvas-side-panel-store";
|
||||||
import { useThemeStore } from "@/stores/use-theme-store";
|
import { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||||
@@ -443,9 +444,10 @@ function AssetCover({ asset }: { asset: Asset }) {
|
|||||||
const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
|
const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const sources = usePromptSourceStore((state) => state.sources);
|
const sources = usePromptSourceStore((state) => state.sources);
|
||||||
|
const personalPrompts = usePromptStore((state) => state.prompts);
|
||||||
const enabledSources = useMemo(() => sources.filter((source) => source.enabled), [sources]);
|
const enabledSources = useMemo(() => sources.filter((source) => source.enabled), [sources]);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [expanded, setExpanded] = useState<Record<string, boolean>>(() => (enabledSources[0] ? { [enabledSources[0].id]: true } : {}));
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({ personal: true });
|
||||||
const [detail, setDetail] = useState<Prompt | null>(null);
|
const [detail, setDetail] = useState<Prompt | null>(null);
|
||||||
|
|
||||||
const copyPrompt = async (prompt: string) => {
|
const copyPrompt = async (prompt: string) => {
|
||||||
@@ -463,8 +465,18 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
|
|||||||
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索提示词" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
|
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索提示词" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
|
||||||
{enabledSources.length ? (
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
<PersonalPromptGroup
|
||||||
|
items={personalPrompts.map(personalPromptToPrompt)}
|
||||||
|
keyword={keyword}
|
||||||
|
open={!!expanded.personal}
|
||||||
|
theme={theme}
|
||||||
|
onToggle={() => setExpanded((prev) => ({ ...prev, personal: !prev.personal }))}
|
||||||
|
onInsert={onInsert}
|
||||||
|
onView={setDetail}
|
||||||
|
/>
|
||||||
|
{enabledSources.length ? (
|
||||||
|
<>
|
||||||
{enabledSources.map((source) => (
|
{enabledSources.map((source) => (
|
||||||
<PromptSourceGroup
|
<PromptSourceGroup
|
||||||
key={source.id}
|
key={source.id}
|
||||||
@@ -478,16 +490,40 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
|
|||||||
onView={setDetail}
|
onView={setDetail}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
) : personalPrompts.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" className="pt-12" /> : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词来源" className="pt-16" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => void copyPrompt(prompt)} />
|
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => void copyPrompt(prompt)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function PersonalPromptGroup({ items, keyword, open, theme, onToggle, onInsert, onView }: { items: Prompt[]; keyword: string; open: boolean; theme: CanvasTheme; onToggle: () => void; onInsert: (payload: InsertAssetPayload) => void; onView: (prompt: Prompt) => void }) {
|
||||||
|
const showResults = open || !!keyword.trim();
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const query = keyword.trim().toLowerCase();
|
||||||
|
return query ? items.filter((item) => [item.title, item.prompt, item.description, ...item.tags].join(" ").toLowerCase().includes(query)) : items;
|
||||||
|
}, [items, keyword]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={onToggle} className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100">
|
||||||
|
<ChevronRight className={cn("size-3.5 transition-transform", showResults && "rotate-90")} />
|
||||||
|
<BookOpen className="size-3.5" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">我的提示词</span>
|
||||||
|
<span className="opacity-50">{filtered.length}</span>
|
||||||
|
</button>
|
||||||
|
{showResults ? (
|
||||||
|
<div className="space-y-1.5 px-1 pb-2 pt-1">
|
||||||
|
{filtered.map((item) => <PromptRow key={item.id} item={item} theme={theme} onInsert={() => onInsert({ kind: "text", content: item.prompt, title: item.title })} onView={() => onView(item)} />)}
|
||||||
|
{!filtered.length ? <div className="py-4 text-center text-xs opacity-40">{keyword.trim() ? "无匹配提示词" : "还没有保存提示词"}</div> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PromptSourceGroup({
|
function PromptSourceGroup({
|
||||||
sourceId,
|
sourceId,
|
||||||
sourceName,
|
sourceName,
|
||||||
@@ -508,7 +544,8 @@ function PromptSourceGroup({
|
|||||||
onView: (prompt: Prompt) => void;
|
onView: (prompt: Prompt) => void;
|
||||||
}) {
|
}) {
|
||||||
// 展开过一次即缓存,避免收起后重复请求;搜索命中时也需要拿到数据来计数。
|
// 展开过一次即缓存,避免收起后重复请求;搜索命中时也需要拿到数据来计数。
|
||||||
const query = useQuery({ queryKey: ["side-panel-prompts", sourceId], queryFn: () => fetchSourcePrompts(sourceId), enabled: open, staleTime: 1000 * 60 * 60 });
|
const showResults = open || !!keyword.trim();
|
||||||
|
const query = useQuery({ queryKey: ["side-panel-prompts", sourceId], queryFn: () => fetchSourcePrompts(sourceId), enabled: showResults, staleTime: 1000 * 60 * 60 });
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const items = query.data || [];
|
const items = query.data || [];
|
||||||
@@ -522,12 +559,12 @@ function PromptSourceGroup({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button type="button" onClick={onToggle} className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100">
|
<button type="button" onClick={onToggle} className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100">
|
||||||
<ChevronRight className={cn("size-3.5 transition-transform", open && "rotate-90")} />
|
<ChevronRight className={cn("size-3.5 transition-transform", showResults && "rotate-90")} />
|
||||||
<BookOpen className="size-3.5" />
|
<BookOpen className="size-3.5" />
|
||||||
<span className="min-w-0 flex-1 truncate">{sourceName}</span>
|
<span className="min-w-0 flex-1 truncate">{sourceName}</span>
|
||||||
{open && query.isSuccess ? <span className="opacity-50">{filtered.length}</span> : null}
|
{showResults && query.isSuccess ? <span className="opacity-50">{filtered.length}</span> : null}
|
||||||
</button>
|
</button>
|
||||||
{open ? (
|
{showResults ? (
|
||||||
<div className="px-1 pb-2 pt-1">
|
<div className="px-1 pb-2 pt-1">
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<div className="flex justify-center py-6">
|
<div className="flex justify-center py-6">
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { App, Button, Select, Switch } from "antd";
|
import { App, Button, Select, Switch, Tag } from "antd";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { PromptSourceEditorDrawer } from "./prompt-source-editor-drawer";
|
import { PromptSourceEditorDrawer } from "./prompt-source-editor-drawer";
|
||||||
import { PromptSourceContentModal } from "./prompt-source-content-modal";
|
import { PromptSourceContentModal } from "./prompt-source-content-modal";
|
||||||
import { refreshAllSources, refreshSource } from "@/services/api/prompts";
|
import { fetchPromptSourceStatuses, refreshAllSources, refreshSource } from "@/services/api/prompts";
|
||||||
import { PROMPT_SOURCE_INTERVAL_OPTIONS, usePromptSourceStore } from "@/stores/use-prompt-source-store";
|
import { PROMPT_SOURCE_INTERVAL_OPTIONS, usePromptSourceStore } from "@/stores/use-prompt-source-store";
|
||||||
import type { PromptSource } from "@/services/api/prompt-source-presets";
|
import type { PromptSource } from "@/services/api/prompt-source-presets";
|
||||||
|
|
||||||
|
const STATUS_QUERY_KEY = ["prompt-source-statuses"];
|
||||||
|
|
||||||
export function ConfigPromptSources() {
|
export function ConfigPromptSources() {
|
||||||
const { message } = App.useApp();
|
const { message, modal } = App.useApp();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const sources = usePromptSourceStore((state) => state.sources);
|
const sources = usePromptSourceStore((state) => state.sources);
|
||||||
const schedule = usePromptSourceStore((state) => state.schedule);
|
const schedule = usePromptSourceStore((state) => state.schedule);
|
||||||
@@ -19,20 +21,20 @@ export function ConfigPromptSources() {
|
|||||||
const removeSource = usePromptSourceStore((state) => state.removeSource);
|
const removeSource = usePromptSourceStore((state) => state.removeSource);
|
||||||
const toggleSource = usePromptSourceStore((state) => state.toggleSource);
|
const toggleSource = usePromptSourceStore((state) => state.toggleSource);
|
||||||
const updateSchedule = usePromptSourceStore((state) => state.updateSchedule);
|
const updateSchedule = usePromptSourceStore((state) => state.updateSchedule);
|
||||||
|
const statusQuery = useQuery({ queryKey: STATUS_QUERY_KEY, queryFn: fetchPromptSourceStatuses });
|
||||||
|
|
||||||
const [editingId, setEditingId] = useState("");
|
const [editingSource, setEditingSource] = useState<PromptSource | null>(null);
|
||||||
const [viewingId, setViewingId] = useState("");
|
const [viewingId, setViewingId] = useState("");
|
||||||
const [refreshingId, setRefreshingId] = useState("");
|
const [refreshingId, setRefreshingId] = useState("");
|
||||||
const [refreshingAll, setRefreshingAll] = useState(false);
|
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||||
|
|
||||||
const editingSource = sources.find((item) => item.id === editingId) || null;
|
|
||||||
const viewingSource = sources.find((item) => item.id === viewingId) || null;
|
const viewingSource = sources.find((item) => item.id === viewingId) || null;
|
||||||
|
|
||||||
const invalidatePrompts = () => queryClient.invalidateQueries({ queryKey: ["prompts"] });
|
const invalidatePrompts = async () => {
|
||||||
|
await Promise.all([
|
||||||
const handleAdd = () => {
|
queryClient.invalidateQueries({ queryKey: ["prompts"] }),
|
||||||
const source = addSource();
|
queryClient.invalidateQueries({ queryKey: ["side-panel-prompts"] }),
|
||||||
setEditingId(source.id);
|
queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY }),
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = (source: PromptSource) => {
|
const handleSave = (source: PromptSource) => {
|
||||||
@@ -41,22 +43,28 @@ export function ConfigPromptSources() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (source: PromptSource) => {
|
const handleDelete = (source: PromptSource) => {
|
||||||
if (sources.length <= 1) {
|
modal.confirm({
|
||||||
message.warning("至少保留一个来源");
|
title: `删除「${source.name}」?`,
|
||||||
return;
|
content: "来源配置会被移除,已经保存到我的提示词的内容不受影响。",
|
||||||
}
|
okText: "删除",
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: "取消",
|
||||||
|
onOk: async () => {
|
||||||
removeSource(source.id);
|
removeSource(source.id);
|
||||||
void invalidatePrompts();
|
await invalidatePrompts();
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshOne = async (source: PromptSource) => {
|
const handleRefreshOne = async (source: PromptSource) => {
|
||||||
setRefreshingId(source.id);
|
setRefreshingId(source.id);
|
||||||
try {
|
try {
|
||||||
const count = await refreshSource(source.id);
|
const result = await refreshSource(source.id);
|
||||||
await invalidatePrompts();
|
await invalidatePrompts();
|
||||||
message.success(`「${source.name}」已拉取 ${count} 条`);
|
message.success(`「${source.name}」已更新 ${result.count} 条`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : "拉取失败");
|
await queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY });
|
||||||
|
message.error(error instanceof Error ? error.message : "更新失败,已保留旧缓存");
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshingId("");
|
setRefreshingId("");
|
||||||
}
|
}
|
||||||
@@ -65,12 +73,13 @@ export function ConfigPromptSources() {
|
|||||||
const handleRefreshAll = async () => {
|
const handleRefreshAll = async () => {
|
||||||
setRefreshingAll(true);
|
setRefreshingAll(true);
|
||||||
try {
|
try {
|
||||||
const count = await refreshAllSources();
|
const result = await refreshAllSources();
|
||||||
updateSchedule("lastFetchedAt", new Date().toISOString());
|
updateSchedule("lastFetchedAt", new Date().toISOString());
|
||||||
await invalidatePrompts();
|
await invalidatePrompts();
|
||||||
message.success(`全部来源已拉取,共 ${count} 条`);
|
if (result.failureCount) message.warning(`更新完成:${result.successCount} 个成功,${result.failureCount} 个失败,失败来源已保留旧缓存`);
|
||||||
|
else message.success(`已更新 ${result.successCount} 个来源,共 ${result.total} 条`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : "拉取失败");
|
message.error(error instanceof Error ? error.message : "更新失败");
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshingAll(false);
|
setRefreshingAll(false);
|
||||||
}
|
}
|
||||||
@@ -78,40 +87,48 @@ export function ConfigPromptSources() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div className="mb-4 flex flex-wrap items-center justify-end gap-3">
|
||||||
<div className="text-xs text-stone-500">每个来源是一段拉取脚本,可自定义、可查看内容;系统内置几个默认来源,你也可以本地新增。</div>
|
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => setEditingSource(addSource())}>
|
||||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={handleAdd}>
|
|
||||||
新增来源
|
新增来源
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{sources.map((source) => (
|
{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">
|
const status = statusQuery.data?.[source.id];
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
return (
|
||||||
<Switch size="small" checked={source.enabled} onChange={(checked) => toggleSource(source.id, checked)} />
|
<div key={source.id} className="flex flex-wrap items-center gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
|
||||||
<div className="min-w-0">
|
<Switch size="small" checked={source.enabled} onChange={(checked) => { toggleSource(source.id, checked); void invalidatePrompts(); }} />
|
||||||
<div className="truncate text-sm font-semibold">{source.name || "未命名来源"}</div>
|
<div className="min-w-[220px] flex-1">
|
||||||
<div className="mt-1 truncate text-xs text-stone-500">{source.githubUrl || "无 GitHub 地址"}</div>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-semibold">{source.name}</span>
|
||||||
|
{source.builtIn ? <Tag className="m-0 shrink-0 text-[10px]">内置</Tag> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
|
||||||
|
<a className="max-w-full truncate hover:text-stone-800 hover:underline dark:hover:text-stone-200" href={source.homepage || source.url} target="_blank" rel="noreferrer">
|
||||||
|
{source.homepage || source.url}
|
||||||
|
</a>
|
||||||
|
<span className="tabular-nums">{status?.count ?? 0} 条</span>
|
||||||
|
{status?.lastError ? <Tag color="error" className="m-0 text-[10px]" title={status.lastError}>失败</Tag> : status?.lastSuccessAt ? <Tag color="success" className="m-0 text-[10px]">正常</Tag> : <Tag className="m-0 text-[10px]">未同步</Tag>}
|
||||||
|
<span>{status?.lastSuccessAt ? `上次成功 ${formatTime(status.lastSuccessAt)}` : "尚未拉取"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-2">
|
<div className="ml-auto flex flex-wrap justify-end gap-2">
|
||||||
<Button size="small" icon={<Eye className="size-3.5" />} onClick={() => setViewingId(source.id)}>
|
<Button size="small" icon={<Eye className="size-3.5" />} onClick={() => setViewingId(source.id)}>
|
||||||
查看内容
|
查看内容
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={refreshingId === source.id} onClick={() => void handleRefreshOne(source)}>
|
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={refreshingId === source.id} onClick={() => void handleRefreshOne(source)}>
|
||||||
立即拉取
|
立即拉取
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingId(source.id)}>
|
{!source.builtIn ? <Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingSource(source)}>编辑来源</Button> : null}
|
||||||
编辑脚本
|
{!source.builtIn ? <Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(source)}>删除</Button> : null}
|
||||||
</Button>
|
|
||||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(source)} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="mt-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
<section className="mt-5 rounded-lg border border-stone-200 p-4 dark:border-stone-800">
|
||||||
<div className="mb-3 text-sm font-semibold">定时拉取</div>
|
<div className="mb-3 text-sm font-semibold">定时拉取</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -126,12 +143,13 @@ export function ConfigPromptSources() {
|
|||||||
<div className="mt-2 text-xs text-stone-400">开启周期后,页面打开期间会按周期自动拉取所有启用的来源。</div>
|
<div className="mt-2 text-xs text-stone-400">开启周期后,页面打开期间会按周期自动拉取所有启用的来源。</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<PromptSourceEditorDrawer open={Boolean(editingSource)} source={editingSource} onSave={handleSave} onClose={() => setEditingId("")} />
|
<PromptSourceEditorDrawer open={Boolean(editingSource)} source={editingSource} onSave={handleSave} onClose={() => setEditingSource(null)} />
|
||||||
<PromptSourceContentModal source={viewingSource} onClose={() => setViewingId("")} />
|
<PromptSourceContentModal source={viewingSource} onClose={() => setViewingId("")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(value: string) {
|
function formatTime(value: string) {
|
||||||
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
|
|||||||
<div className="mt-0.5 text-xs font-normal text-stone-500">共 {items.length} 条</div>
|
<div className="mt-0.5 text-xs font-normal text-stone-500">共 {items.length} 条</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void load(true)}>
|
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void load(true)}>
|
||||||
立即拉取
|
立即更新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { javascript } from "@codemirror/lang-javascript";
|
import { App, Button, Drawer, Input, Space, Switch } from "antd";
|
||||||
import CodeMirror from "@uiw/react-codemirror";
|
|
||||||
import { Button, Drawer, Input, Space } from "antd";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { PROMPT_SOURCE_VARIABLES } from "@/services/api/prompt-source-runtime";
|
import type { PromptSource } from "@/services/api/prompt-source-presets";
|
||||||
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 }) {
|
export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { open: boolean; source: PromptSource | null; onSave: (source: PromptSource) => void; onClose: () => void }) {
|
||||||
|
const { message } = App.useApp();
|
||||||
const [draft, setDraft] = useState<PromptSource | null>(source);
|
const [draft, setDraft] = useState<PromptSource | null>(source);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -22,15 +16,20 @@ export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { op
|
|||||||
const patch = (value: Partial<PromptSource>) => setDraft((current) => (current ? { ...current, ...value } : current));
|
const patch = (value: Partial<PromptSource>) => setDraft((current) => (current ? { ...current, ...value } : current));
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
onSave({ ...draft, name: draft.name.trim() || "未命名来源", githubUrl: draft.githubUrl.trim(), script: draft.script.trim() });
|
const name = draft.name.trim();
|
||||||
|
const url = draft.url.trim();
|
||||||
|
if (!name) return message.warning("请输入来源名称");
|
||||||
|
if (!isHttpUrl(url)) return message.warning("请输入有效的 JSON URL");
|
||||||
|
if (draft.homepage.trim() && !isHttpUrl(draft.homepage.trim())) return message.warning("请输入有效的主页地址");
|
||||||
|
onSave({ ...draft, name, url, homepage: draft.homepage.trim(), builtIn: false });
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
open={open}
|
open={open}
|
||||||
width={880}
|
width={560}
|
||||||
title="编辑提示词来源"
|
title={source?.name === "新来源" ? "新增提示词来源" : "编辑提示词来源"}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
styles={{ body: { paddingTop: 16 } }}
|
styles={{ body: { paddingTop: 16 } }}
|
||||||
extra={
|
extra={
|
||||||
@@ -42,67 +41,46 @@ export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { op
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="space-y-5">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1 block text-sm font-medium">来源名称</span>
|
<span className="mb-1.5 block text-sm font-medium">来源名称</span>
|
||||||
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} placeholder="用于分类展示" />
|
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} placeholder="用于分类展示" />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-1 block text-sm font-medium">GitHub 地址(可选)</span>
|
<span className="mb-1.5 block text-sm font-medium">JSON URL</span>
|
||||||
<Input value={draft.githubUrl} onChange={(event) => patch({ githubUrl: event.target.value })} placeholder="https://github.com/owner/repo" />
|
<Input value={draft.url} onChange={(event) => patch({ url: event.target.value })} placeholder="https://example.com/prompts.json" />
|
||||||
</label>
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">来源主页(可选)</span>
|
||||||
|
<Input value={draft.homepage} onChange={(event) => patch({ homepage: event.target.value })} placeholder="https://example.com" />
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center justify-between border-y border-stone-200 py-3 dark:border-stone-800">
|
||||||
|
<span className="text-sm font-medium">启用来源</span>
|
||||||
|
<Switch checked={draft.enabled} onChange={(enabled) => patch({ enabled })} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold">拉取脚本</div>
|
<div className="mb-2 text-sm font-medium">JSON 格式</div>
|
||||||
<div className="mt-0.5 text-xs text-stone-500">脚本是一段异步函数体,直接使用下方变量,最后 return 一个提示词数组(每条至少含 title 和 prompt)。</div>
|
<pre className="overflow-x-auto rounded-md bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{`[
|
||||||
</div>
|
{
|
||||||
<Button size="small" onClick={() => patch({ script: PROMPT_SOURCE_TEMPLATE })}>
|
"id": "product-photo-1",
|
||||||
插入模板
|
"title": "白底商品图",
|
||||||
</Button>
|
"prompt": "生成专业白底商品摄影图",
|
||||||
</div>
|
"description": "",
|
||||||
|
"coverUrl": "",
|
||||||
<div className="flex h-[56vh] min-h-[420px] overflow-hidden rounded-lg border border-stone-200 dark:border-stone-800">
|
"referenceImageUrls": [],
|
||||||
<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">
|
"tags": ["商品", "摄影"]
|
||||||
<div className="px-4 py-3">
|
}
|
||||||
<div className="mb-2.5 flex items-center justify-between">
|
]`}</pre>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(value: string) {
|
||||||
|
try {
|
||||||
|
return ["http:", "https:"].includes(new URL(value).protocol);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Copy } from "lucide-react";
|
import { Copy, FileText } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Button, Card, Tag } from "antd";
|
import { Button, Card, Tag } from "antd";
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export function PromptCard({
|
|||||||
styles={{ body: { padding: 0 } }}
|
styles={{ body: { padding: 0 } }}
|
||||||
cover={
|
cover={
|
||||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||||
<img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" />
|
{item.coverUrl ? <img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" loading="lazy" /> : <span className="grid aspect-[4/3] w-full place-items-center bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600"><FileText className="size-8" /></span>}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -38,7 +38,7 @@ export function PromptCard({
|
|||||||
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2>
|
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2>
|
||||||
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt)}</span>
|
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.prompt}</p>
|
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.description || item.prompt}</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
{item.tags.map((tag) => (
|
{item.tags.map((tag) => (
|
||||||
<Tag key={tag} className="m-0 text-[11px]">
|
<Tag key={tag} className="m-0 text-[11px]">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
|
|||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
|
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
|
||||||
const { query, items, tags: promptTags, categories: promptCategories } = usePromptList({ keyword, tags: selectedTags, category: selectedCategory, enabled: open });
|
const { query, items, tags: promptTags, categories: promptCategories } = usePromptList({ keyword, tags: selectedTags, category: selectedCategory, enabled: open, includePersonal: true });
|
||||||
const toggleTag = (tag: string) => {
|
const toggleTag = (tag: string) => {
|
||||||
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
|
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
|
||||||
setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
|
setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { ALL_PROMPTS_OPTION, fetchPrompts } from "@/services/api/prompts";
|
|||||||
|
|
||||||
export const PROMPT_PAGE_SIZE = 20;
|
export const PROMPT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
export function usePromptList({ keyword, tags, category, enabled = true }: { keyword: string; tags: string[]; category: string; enabled?: boolean }) {
|
export function usePromptList({ keyword, tags, category, enabled = true, includePersonal = false }: { keyword: string; tags: string[]; category: string; enabled?: boolean; includePersonal?: boolean }) {
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: ["prompts", keyword, tags, category],
|
queryKey: ["prompts", keyword, tags, category, includePersonal],
|
||||||
queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }),
|
queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE, includePersonal }),
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
getNextPageParam: (lastPage, pages) => (pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined),
|
getNextPageParam: (lastPage, pages) => (pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined),
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { refreshAllSources } from "@/services/api/prompts";
|
import { refreshDueSources } from "@/services/api/prompts";
|
||||||
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
|
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
|
||||||
|
|
||||||
const CHECK_INTERVAL_MS = 60_000;
|
const CHECK_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
/** Periodically refetch all enabled prompt sources while the app is open, based on the global schedule. */
|
/** Periodically update only the sources whose last successful refresh is due. */
|
||||||
export function usePromptSourceScheduler() {
|
export function usePromptSourceScheduler() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const intervalMinutes = usePromptSourceStore((state) => state.schedule.intervalMinutes);
|
const intervalMinutes = usePromptSourceStore((state) => state.schedule.intervalMinutes);
|
||||||
@@ -16,16 +16,19 @@ export function usePromptSourceScheduler() {
|
|||||||
let running = false;
|
let running = false;
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
if (running) return;
|
if (running) return;
|
||||||
const { schedule, updateSchedule } = usePromptSourceStore.getState();
|
const { updateSchedule } = usePromptSourceStore.getState();
|
||||||
const last = schedule.lastFetchedAt ? new Date(schedule.lastFetchedAt).getTime() : 0;
|
|
||||||
if (Date.now() - last < intervalMinutes * 60_000) return;
|
|
||||||
running = true;
|
running = true;
|
||||||
try {
|
try {
|
||||||
await refreshAllSources();
|
const result = await refreshDueSources(intervalMinutes * 60_000);
|
||||||
|
if (!result.results.length) return;
|
||||||
updateSchedule("lastFetchedAt", new Date().toISOString());
|
updateSchedule("lastFetchedAt", new Date().toISOString());
|
||||||
await queryClient.invalidateQueries({ queryKey: ["prompts"] });
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["prompts"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["side-panel-prompts"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["prompt-source-statuses"] }),
|
||||||
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
// 拉取失败时静默重试,等待下一个检查周期。
|
// 单个来源的错误已写入来源状态,下一个检查周期会继续尝试。
|
||||||
} finally {
|
} finally {
|
||||||
running = false;
|
running = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2591,11 +2591,11 @@ function InfiniteCanvasPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const insertAssistantText = useCallback(
|
const insertAssistantText = useCallback(
|
||||||
(text: string) => {
|
(text: string, title?: string) => {
|
||||||
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
|
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
|
||||||
const node = {
|
const node = {
|
||||||
...createCanvasNode(CanvasNodeType.Text, center, { content: text, status: NODE_STATUS_SUCCESS }),
|
...createCanvasNode(CanvasNodeType.Text, center, { content: text, status: NODE_STATUS_SUCCESS }),
|
||||||
title: text.slice(0, 32) || "Assistant Text",
|
title: title || text.slice(0, 32) || "Assistant Text",
|
||||||
};
|
};
|
||||||
|
|
||||||
setNodes((prev) => [...prev, node]);
|
setNodes((prev) => [...prev, node]);
|
||||||
@@ -2608,7 +2608,7 @@ function InfiniteCanvasPage() {
|
|||||||
const handleAssetInsert = useCallback(
|
const handleAssetInsert = useCallback(
|
||||||
(payload: InsertAssetPayload) => {
|
(payload: InsertAssetPayload) => {
|
||||||
if (payload.kind === "text") {
|
if (payload.kind === "text") {
|
||||||
insertAssistantText(payload.content);
|
insertAssistantText(payload.content, payload.title);
|
||||||
} else if (payload.kind === "video") {
|
} else if (payload.kind === "video") {
|
||||||
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
|
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
|
||||||
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
|
const center = screenToCanvas((containerRef.current?.getBoundingClientRect().left || 0) + size.width / 2, (containerRef.current?.getBoundingClientRect().top || 0) + size.height / 2);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { App, Button, Input, Modal, Space } from "antd";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { PersonalPrompt, PersonalPromptInput } from "@/stores/use-prompt-store";
|
||||||
|
|
||||||
|
const EMPTY_PROMPT: PersonalPromptInput = {
|
||||||
|
title: "",
|
||||||
|
prompt: "",
|
||||||
|
description: "",
|
||||||
|
coverUrl: "",
|
||||||
|
referenceImageUrls: [],
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MyPromptEditorDialog({ open, prompt, onSave, onClose }: { open: boolean; prompt: PersonalPrompt | null; onSave: (value: PersonalPromptInput) => void; onClose: () => void }) {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const [draft, setDraft] = useState<PersonalPromptInput>(EMPTY_PROMPT);
|
||||||
|
const [tags, setTags] = useState("");
|
||||||
|
const [referenceImages, setReferenceImages] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setDraft(prompt ? toInput(prompt) : EMPTY_PROMPT);
|
||||||
|
setTags(prompt?.tags.join(", ") || "");
|
||||||
|
setReferenceImages(prompt?.referenceImageUrls.join("\n") || "");
|
||||||
|
}, [open, prompt]);
|
||||||
|
|
||||||
|
const patch = (value: Partial<PersonalPromptInput>) => setDraft((current) => ({ ...current, ...value }));
|
||||||
|
const save = () => {
|
||||||
|
if (!draft.title.trim()) return message.warning("请输入标题");
|
||||||
|
if (!draft.prompt.trim()) return message.warning("请输入提示词");
|
||||||
|
onSave({
|
||||||
|
...draft,
|
||||||
|
title: draft.title.trim(),
|
||||||
|
prompt: draft.prompt.trim(),
|
||||||
|
description: draft.description.trim(),
|
||||||
|
coverUrl: draft.coverUrl.trim(),
|
||||||
|
tags: splitValues(tags, /[,,\n]/),
|
||||||
|
referenceImageUrls: splitValues(referenceImages, /\n/),
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={prompt ? "编辑提示词" : "新增提示词"}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
width={680}
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onClose}>取消</Button>
|
||||||
|
<Button type="primary" onClick={save}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 pt-2">
|
||||||
|
<label>
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">标题</span>
|
||||||
|
<Input value={draft.title} onChange={(event) => patch({ title: event.target.value })} placeholder="例如:白底商品图" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">提示词</span>
|
||||||
|
<Input.TextArea rows={7} value={draft.prompt} onChange={(event) => patch({ prompt: event.target.value })} placeholder="输入可直接使用的提示词" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">说明(可选)</span>
|
||||||
|
<Input.TextArea rows={2} value={draft.description} onChange={(event) => patch({ description: event.target.value })} />
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label>
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">封面 URL(可选)</span>
|
||||||
|
<Input value={draft.coverUrl} onChange={(event) => patch({ coverUrl: event.target.value })} placeholder="https://..." />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">标签(可选)</span>
|
||||||
|
<Input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="商品, 摄影" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span className="mb-1.5 block text-sm font-medium">参考图 URL(可选,每行一个)</span>
|
||||||
|
<Input.TextArea rows={3} value={referenceImages} onChange={(event) => setReferenceImages(event.target.value)} placeholder={"https://...\nhttps://..."} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInput(prompt: PersonalPrompt): PersonalPromptInput {
|
||||||
|
return {
|
||||||
|
title: prompt.title,
|
||||||
|
prompt: prompt.prompt,
|
||||||
|
description: prompt.description,
|
||||||
|
coverUrl: prompt.coverUrl,
|
||||||
|
referenceImageUrls: prompt.referenceImageUrls,
|
||||||
|
tags: prompt.tags,
|
||||||
|
imageMode: prompt.imageMode,
|
||||||
|
imageModel: prompt.imageModel,
|
||||||
|
imageSize: prompt.imageSize,
|
||||||
|
imageCount: prompt.imageCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitValues(value: string, separator: RegExp) {
|
||||||
|
return Array.from(new Set(value.split(separator).map((item) => item.trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Copy, FolderPlus } from "lucide-react";
|
import { BookmarkPlus, Copy, FileText, FolderPlus } from "lucide-react";
|
||||||
import { Button, Modal, Space, Tag } from "antd";
|
import { Button, Modal, Space, Tag } from "antd";
|
||||||
|
|
||||||
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
|
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
|
||||||
|
|
||||||
export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) {
|
export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset, onSavePrompt }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void; onSavePrompt?: (prompt: Prompt) => void }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}>
|
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}>
|
||||||
@@ -11,7 +11,8 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
|
|||||||
<>
|
<>
|
||||||
<div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]">
|
<div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<img src={prompt.coverUrl} alt={prompt.title} className="aspect-[4/3] w-full rounded-lg object-cover" />
|
{prompt.coverUrl ? <img src={prompt.coverUrl} alt={prompt.title} className="aspect-[4/3] w-full rounded-lg object-cover" /> : <div className="grid aspect-[4/3] w-full place-items-center rounded-lg bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600"><FileText className="size-9" /></div>}
|
||||||
|
{prompt.referenceImageUrls.length > 1 ? <div className="grid grid-cols-3 gap-2">{prompt.referenceImageUrls.filter((url) => url !== prompt.coverUrl).slice(0, 6).map((url) => <img key={url} src={url} alt="" className="aspect-square w-full rounded-md object-cover" loading="lazy" />)}</div> : null}
|
||||||
{prompt.preview ? <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
|
{prompt.preview ? <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -22,10 +23,9 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
|
|||||||
</Tag>
|
</Tag>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{prompt.description ? <p className="mt-4 text-sm leading-6 text-stone-500 dark:text-stone-400">{prompt.description}</p> : null}
|
||||||
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
|
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
|
||||||
<div className="mt-4 text-xs text-stone-500 dark:text-stone-400">
|
{prompt.createdAt || prompt.updatedAt ? <div className="mt-4 text-xs text-stone-500 dark:text-stone-400">{prompt.createdAt ? `创建:${formatPromptDate(prompt.createdAt)}` : null}{prompt.createdAt && prompt.updatedAt ? " · " : null}{prompt.updatedAt ? `更新:${formatPromptDate(prompt.updatedAt)}` : null}</div> : null}
|
||||||
创建:{formatPromptDate(prompt.createdAt)} · 更新:{formatPromptDate(prompt.updatedAt)}
|
|
||||||
</div>
|
|
||||||
<Space wrap className="mt-5">
|
<Space wrap className="mt-5">
|
||||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
|
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}>
|
||||||
复制提示词
|
复制提示词
|
||||||
@@ -35,6 +35,11 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p
|
|||||||
加入我的资产
|
加入我的资产
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{onSavePrompt ? (
|
||||||
|
<Button icon={<BookmarkPlus className="size-4" />} onClick={() => onSavePrompt(prompt)}>
|
||||||
|
保存到我的提示词
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,41 @@
|
|||||||
import { FolderPlus, Search } from "lucide-react";
|
import { BookmarkPlus, FolderPlus, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||||
import { type UIEvent, useEffect, useState } from "react";
|
import { type ReactNode, type UIEvent, useEffect, useMemo, useState } from "react";
|
||||||
import { App, Button, Empty, Input, Spin, Tag } from "antd";
|
import { App, Button, Empty, Input, Popconfirm, Space, Spin, Tabs, Tag, Tooltip } from "antd";
|
||||||
|
|
||||||
import { PromptCard } from "@/components/prompts/prompt-card";
|
import { PromptCard } from "@/components/prompts/prompt-card";
|
||||||
import { usePromptList } from "@/components/prompts/use-prompt-list";
|
import { usePromptList } from "@/components/prompts/use-prompt-list";
|
||||||
|
import { MyPromptEditorDialog } from "./components/my-prompt-editor-dialog";
|
||||||
import { PromptDetailDialog } from "./components/prompt-detail-dialog";
|
import { PromptDetailDialog } from "./components/prompt-detail-dialog";
|
||||||
import { useCopyText } from "@/hooks/use-copy-text";
|
import { useCopyText } from "@/hooks/use-copy-text";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAssetStore } from "@/stores/use-asset-store";
|
import { useAssetStore } from "@/stores/use-asset-store";
|
||||||
import { ALL_PROMPTS_OPTION, type Prompt } from "@/services/api/prompts";
|
import { usePromptStore, type PersonalPrompt, type PersonalPromptInput } from "@/stores/use-prompt-store";
|
||||||
|
import { ALL_PROMPTS_OPTION, personalPromptToPrompt, type Prompt } from "@/services/api/prompts";
|
||||||
|
|
||||||
export default function PromptsPage() {
|
export default function PromptsPage() {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
const [activeTab, setActiveTab] = useState("library");
|
||||||
const [titleKeyword, setTitleKeyword] = useState("");
|
const [titleKeyword, setTitleKeyword] = useState("");
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
|
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
|
||||||
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
|
const [selectedPrompt, setSelectedPrompt] = useState<Prompt | null>(null);
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editingPrompt, setEditingPrompt] = useState<PersonalPrompt | null>(null);
|
||||||
const addAsset = useAssetStore((state) => state.addAsset);
|
const addAsset = useAssetStore((state) => state.addAsset);
|
||||||
|
const personalPrompts = usePromptStore((state) => state.prompts);
|
||||||
|
const addPrompt = usePromptStore((state) => state.addPrompt);
|
||||||
|
const updatePrompt = usePromptStore((state) => state.updatePrompt);
|
||||||
|
const removePrompt = usePromptStore((state) => state.removePrompt);
|
||||||
const copyText = useCopyText();
|
const copyText = useCopyText();
|
||||||
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory });
|
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory, enabled: activeTab === "library" });
|
||||||
|
const filteredPersonalPrompts = useMemo(() => {
|
||||||
|
const keyword = titleKeyword.trim().toLowerCase();
|
||||||
|
if (!keyword) return personalPrompts;
|
||||||
|
return personalPrompts.filter((item) => [item.title, item.prompt, item.description, ...item.tags].join(" ").toLowerCase().includes(keyword));
|
||||||
|
}, [personalPrompts, titleKeyword]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.isError) {
|
if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
|
||||||
message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
|
|
||||||
}
|
|
||||||
}, [message, query.error, query.isError]);
|
}, [message, query.error, query.isError]);
|
||||||
|
|
||||||
const toggleTag = (tag: string) => {
|
const toggleTag = (tag: string) => {
|
||||||
@@ -36,91 +48,104 @@ export default function PromptsPage() {
|
|||||||
message.success("已加入我的资产");
|
message.success("已加入我的资产");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
const saveToMyPrompts = (item: Prompt) => {
|
||||||
const target = event.currentTarget;
|
addPrompt(toPersonalInput(item));
|
||||||
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) {
|
message.success("已保存到我的提示词");
|
||||||
void query.fetchNextPage();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openNewPrompt = () => {
|
||||||
|
setEditingPrompt(null);
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditPrompt = (item: PersonalPrompt) => {
|
||||||
|
setEditingPrompt(item);
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePersonalPrompt = (input: PersonalPromptInput) => {
|
||||||
|
if (editingPrompt) updatePrompt(editingPrompt.id, input);
|
||||||
|
else addPrompt(input);
|
||||||
|
message.success(editingPrompt ? "提示词已更新" : "提示词已添加");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
|
||||||
|
if (activeTab !== "library") return;
|
||||||
|
const target = event.currentTarget;
|
||||||
|
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) void query.fetchNextPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
const personalItems = filteredPersonalPrompts.map(personalPromptToPrompt);
|
||||||
|
const visibleCount = activeTab === "library" ? totalPrompts : filteredPersonalPrompts.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
|
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
|
||||||
<main
|
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]" onScroll={handleListScroll}>
|
||||||
className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]"
|
<div className="mx-auto max-w-7xl pb-8">
|
||||||
onScroll={handleListScroll}
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
>
|
<div>
|
||||||
<div className="pb-8">
|
<h1 className="text-3xl font-semibold text-stone-950 dark:text-stone-100">提示词中心</h1>
|
||||||
<div className="mx-auto max-w-5xl text-center">
|
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">当前共 {visibleCount} 条提示词</p>
|
||||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">提示词中心</h1>
|
|
||||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">共 {totalPrompts} 条提示词,按标题、标签与分类快速查找灵感。</p>
|
|
||||||
</div>
|
|
||||||
{query.isLoading ? (
|
|
||||||
<div className="flex h-60 items-center justify-center">
|
|
||||||
<Spin />
|
|
||||||
</div>
|
</div>
|
||||||
|
{activeTab === "personal" ? (
|
||||||
|
<Button type="primary" icon={<Plus className="size-4" />} onClick={openNewPrompt}>
|
||||||
|
新增提示词
|
||||||
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{!query.isLoading ? (
|
|
||||||
<>
|
|
||||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
|
||||||
<Input size="large" className="w-full" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="按标题查询" onChange={(event) => setTitleKeyword(event.target.value)} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<Tabs className="mt-5" activeKey={activeTab} onChange={setActiveTab} items={[{ key: "library", label: "提示词库" }, { key: "personal", label: `我的提示词 (${personalPrompts.length})` }]} />
|
||||||
|
<div className="mx-auto mt-2 w-full max-w-2xl">
|
||||||
|
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="搜索标题、内容或标签" onChange={(event) => setTitleKeyword(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
{activeTab === "library" ? (
|
||||||
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
|
<div className="mx-auto mt-6 grid max-w-6xl gap-3 text-left">
|
||||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
<PromptFilter label="分类" options={promptCategoryOptions} selected={selectedCategory} onChange={setSelectedCategory} />
|
||||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">分类</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{promptCategoryOptions.map((category) => (
|
|
||||||
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
|
|
||||||
{category}
|
|
||||||
</Tag.CheckableTag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">标签</div>
|
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">标签</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{promptTags.map((tag) => (
|
{promptTags.map((tag) => {
|
||||||
<Tag.CheckableTag
|
const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
|
||||||
key={tag}
|
return <Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>{tag}</Tag.CheckableTag>;
|
||||||
checked={tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)}
|
})}
|
||||||
className={cn("prompt-filter-tag", (tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag)) && "is-active")}
|
|
||||||
onChange={() => toggleTag(tag)}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</Tag.CheckableTag>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!query.isLoading ? (
|
{activeTab === "library" && query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
|
||||||
<div>
|
{activeTab === "library" && !query.isLoading ? (
|
||||||
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
<PromptGrid items={promptItems} onOpen={setSelectedPrompt} renderActions={(item) => <><Button size="small" icon={<BookmarkPlus className="size-3.5" />} onClick={() => saveToMyPrompts(item)}>保存</Button><Tooltip title="加入我的资产"><Button type="text" size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)} /></Tooltip></>} onCopy={(item) => copyText(item.prompt, "提示词已复制")} emptyText="没有找到匹配的提示词" />
|
||||||
{promptItems.map((item) => (
|
|
||||||
<PromptCard
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
onOpen={() => setSelectedPrompt(item)}
|
|
||||||
onCopy={() => copyText(item.prompt, "提示词已复制")}
|
|
||||||
extraAction={
|
|
||||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}>
|
|
||||||
加入我的资产
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
|
|
||||||
<div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">
|
|
||||||
{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
{activeTab === "personal" ? (
|
||||||
|
<PromptGrid
|
||||||
|
items={personalItems}
|
||||||
|
onOpen={setSelectedPrompt}
|
||||||
|
onCopy={(item) => copyText(item.prompt, "提示词已复制")}
|
||||||
|
renderActions={(item) => {
|
||||||
|
const personal = personalPrompts.find((prompt) => prompt.id === item.id)!;
|
||||||
|
return <Space size={0}><Tooltip title="编辑"><Button type="text" size="small" icon={<Pencil className="size-3.5" />} onClick={() => openEditPrompt(personal)} /></Tooltip><Popconfirm title="删除这条提示词?" okText="删除" cancelText="取消" onConfirm={() => removePrompt(item.id)}><Tooltip title="删除"><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} /></Tooltip></Popconfirm></Space>;
|
||||||
|
}}
|
||||||
|
emptyText="还没有保存提示词"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{activeTab === "library" ? <div className="mx-auto mt-6 max-w-7xl text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}</div> : null}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} />
|
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={selectedPrompt?.sourceId === "personal" ? undefined : savePromptAsset} onSavePrompt={selectedPrompt?.sourceId === "personal" ? undefined : saveToMyPrompts} />
|
||||||
|
<MyPromptEditorDialog open={editorOpen} prompt={editingPrompt} onSave={savePersonalPrompt} onClose={() => setEditorOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PromptFilter({ label, options, selected, onChange }: { label: string; options: string[]; selected: string; onChange: (value: string) => void }) {
|
||||||
|
return <div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start"><div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">{label}</div><div className="flex flex-wrap gap-2">{options.map((option) => <Tag.CheckableTag key={option} checked={selected === option} className={cn("prompt-filter-tag", selected === option && "is-active")} onChange={() => onChange(option)}>{option}</Tag.CheckableTag>)}</div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptGrid({ items, onOpen, onCopy, renderActions, emptyText }: { items: Prompt[]; onOpen: (item: Prompt) => void; onCopy: (item: Prompt) => void; renderActions: (item: Prompt) => ReactNode; emptyText: string }) {
|
||||||
|
return <div><div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{items.map((item) => <PromptCard key={`${item.sourceId}:${item.id}`} item={item} onOpen={() => onOpen(item)} onCopy={() => onCopy(item)} extraAction={renderActions(item)} />)}</div>{items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyText} className="py-16" /> : null}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPersonalInput(item: Prompt): PersonalPromptInput {
|
||||||
|
return { title: item.title, prompt: item.prompt, description: item.description, coverUrl: item.coverUrl, referenceImageUrls: item.referenceImageUrls, tags: item.tags, imageMode: item.imageMode, imageModel: item.imageModel, imageSize: item.imageSize, imageCount: item.imageCount };
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,116 +3,35 @@ import { nanoid } from "nanoid";
|
|||||||
export type PromptSource = {
|
export type PromptSource = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
githubUrl: string;
|
url: string;
|
||||||
|
homepage: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
script: string;
|
builtIn: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PROMPT_REGISTRY_HOMEPAGE = "https://github.com/yukkcat/image-prompts";
|
||||||
|
const PROMPT_REGISTRY_SOURCE_BASE = "https://raw.githubusercontent.com/yukkcat/image-prompts/main/dist/sources";
|
||||||
|
|
||||||
export function createPromptSource(source?: Partial<PromptSource>): PromptSource {
|
export function createPromptSource(source?: Partial<PromptSource>): PromptSource {
|
||||||
return {
|
return {
|
||||||
id: source?.id?.trim() || nanoid(),
|
id: source?.id?.trim() || nanoid(),
|
||||||
name: source?.name?.trim() || "新来源",
|
name: source?.name?.trim() || "新来源",
|
||||||
githubUrl: source?.githubUrl?.trim() || "",
|
url: source?.url?.trim() || "",
|
||||||
|
homepage: source?.homepage?.trim() || "",
|
||||||
enabled: source?.enabled ?? true,
|
enabled: source?.enabled ?? true,
|
||||||
script: source?.script ?? "",
|
builtIn: source?.builtIn ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ? \`\` : ""].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[] = [
|
export const DEFAULT_PROMPT_SOURCES: PromptSource[] = [
|
||||||
{ id: "davidwu-gpt-image2-prompts", name: "davidwu-gpt-image2-prompts", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", enabled: true, script: davidWuGptImage2Script },
|
registrySource("banana-prompt-quicker", "Banana Prompt Quicker", "https://glidea.github.io/banana-prompt-quicker/"),
|
||||||
{ id: "awesome-gpt-image", name: "awesome-gpt-image", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", enabled: true, script: awesomeGptImageScript },
|
registrySource("davidwu-gpt-image2-prompts", "DavidWu GPT Image 2", "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts"),
|
||||||
{ id: "awesome-gpt4o-image-prompts", name: "awesome-gpt4o-image-prompts", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", enabled: true, script: awesomeGpt4oImageScript },
|
registrySource("awesome-gpt-image", "Awesome GPT Image", "https://github.com/ZeroLu/awesome-gpt-image"),
|
||||||
{
|
registrySource("awesome-gpt4o-image-prompts", "Awesome GPT-4o", "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts"),
|
||||||
id: "youmind-gpt-image-2",
|
registrySource("youmind-gpt-image-2", "YouMind GPT Image 2", "https://github.com/YouMind-OpenLab/awesome-gpt-image-2"),
|
||||||
name: "youmind-gpt-image-2",
|
registrySource("youmind-nano-banana-pro", "YouMind Nano Banana Pro", "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts"),
|
||||||
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"),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Starter script inserted when a user creates a blank source. */
|
function registrySource(id: string, name: string, homepage: string): PromptSource {
|
||||||
export const PROMPT_SOURCE_TEMPLATE = `// 拉取远程列表并 return 一个提示词数组;每条至少含 title 和 prompt。
|
return { id, name, url: `${PROMPT_REGISTRY_SOURCE_BASE}/${id}.json`, homepage, enabled: true, builtIn: true };
|
||||||
// 可用辅助见右侧「可用变量」,例如 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;`;
|
|
||||||
|
|||||||
@@ -1,165 +1,118 @@
|
|||||||
/**
|
import type { PromptSource } from "./prompt-source-presets";
|
||||||
* 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 = {
|
export type RawPrompt = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
coverUrl: string;
|
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
description: string;
|
||||||
|
coverUrl: string;
|
||||||
|
referenceImageUrls: string[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
preview: string;
|
preview: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
author?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
|
imageMode?: string;
|
||||||
|
imageModel?: string;
|
||||||
|
imageSize?: string;
|
||||||
|
imageCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RunOptions = { signal?: AbortSignal };
|
type RunOptions = { signal?: AbortSignal };
|
||||||
|
|
||||||
async function fetchText(url: string) {
|
async function fetchSource(source: PromptSource, options?: RunOptions) {
|
||||||
const response = await fetch(url, { cache: "no-store" });
|
const response = await fetch(source.url, { cache: "no-store", signal: options?.signal });
|
||||||
if (!response.ok) throw new Error(`${url} 拉取失败`);
|
if (!response.ok) throw new Error(`请求失败(${response.status})`);
|
||||||
return response.text();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson<T = unknown>(url: string) {
|
export async function runPromptSource(source: PromptSource, options?: RunOptions): Promise<RawPrompt[]> {
|
||||||
return JSON.parse(await fetchText(url)) as T;
|
if (!source.url.trim()) throw new Error("JSON URL 不能为空");
|
||||||
}
|
let data: unknown;
|
||||||
|
try {
|
||||||
/** Split markdown into blocks, each starting at a line that begins with `prefix` (e.g. "## " / "### "). */
|
data = await fetchSource(source, options);
|
||||||
function splitSections(markdown: string, prefix: string) {
|
} catch (error) {
|
||||||
const blocks: string[] = [];
|
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||||
let current: string[] = [];
|
throw new Error(`「${source.name}」拉取失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
for (const line of markdown.split("\n")) {
|
|
||||||
if (line.startsWith(prefix) && current.length) {
|
|
||||||
blocks.push(current.join("\n"));
|
|
||||||
current = [];
|
|
||||||
}
|
}
|
||||||
current.push(line);
|
|
||||||
}
|
const items = parseJsonSource(data, source);
|
||||||
blocks.push(current.join("\n"));
|
if (source.builtIn && !items.length) throw new Error(`「${source.name}」未解析到有效提示词`);
|
||||||
return blocks;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstMatch(value: string, pattern: RegExp) {
|
function parseJsonSource(data: unknown, source: PromptSource) {
|
||||||
return pattern.exec(value)?.[1] || "";
|
if (!Array.isArray(data)) throw new Error(`「${source.name}」格式错误:根节点必须是数组`);
|
||||||
|
return normalizeItems(data, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItems(values: unknown[], source: PromptSource) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const items: RawPrompt[] = [];
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
const record = asRecord(value);
|
||||||
|
const title = stringValue(record.title).trim();
|
||||||
|
const prompt = stringValue(record.prompt).trim();
|
||||||
|
if (!title || !prompt) return;
|
||||||
|
const id = stringValue(record.id).trim() || `${source.id}-${leftPad(index + 1)}`;
|
||||||
|
if (seen.has(id)) return;
|
||||||
|
seen.add(id);
|
||||||
|
const referenceImageUrls = stringArray(record.referenceImageUrls).map((url) => absoluteUrl(source.url, url));
|
||||||
|
const coverUrl = absoluteUrl(source.url, stringValue(record.coverUrl)) || referenceImageUrls[0] || "";
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
prompt,
|
||||||
|
description: stringValue(record.description),
|
||||||
|
coverUrl,
|
||||||
|
referenceImageUrls,
|
||||||
|
tags: stringArray(record.tags),
|
||||||
|
preview: stringValue(record.preview),
|
||||||
|
createdAt: stringValue(record.createdAt),
|
||||||
|
updatedAt: stringValue(record.updatedAt),
|
||||||
|
author: stringValue(record.author),
|
||||||
|
sourceUrl: absoluteUrl(source.url, stringValue(record.sourceUrl)),
|
||||||
|
imageMode: optionalString(record.imageMode),
|
||||||
|
imageModel: optionalString(record.imageModel),
|
||||||
|
imageSize: optionalString(record.imageSize),
|
||||||
|
imageCount: optionalNumber(record.imageCount),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" || typeof value === "number" ? String(value) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArray(value: unknown) {
|
||||||
|
return Array.isArray(value) ? value.map(stringValue).map((item) => item.trim()).filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: unknown) {
|
||||||
|
const result = stringValue(value).trim();
|
||||||
|
return result || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNumber(value: unknown) {
|
||||||
|
const result = Number(value);
|
||||||
|
return Number.isFinite(result) && result > 0 ? result : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function absoluteUrl(baseUrl: string, path: string) {
|
function absoluteUrl(baseUrl: string, path: string) {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (/^https?:\/\//i.test(path)) return path;
|
try {
|
||||||
return `${baseUrl}/${path.replace(/^\.?\//, "")}`;
|
return new URL(path, baseUrl).toString();
|
||||||
}
|
} catch {
|
||||||
|
return path;
|
||||||
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) => ``)
|
|
||||||
.join("\n\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function leftPad(value: number) {
|
function leftPad(value: number) {
|
||||||
return String(value).padStart(4, "0");
|
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: "取消信号,可透传给需要的请求" },
|
|
||||||
];
|
|
||||||
|
|||||||
+140
-46
@@ -2,14 +2,17 @@ import localforage from "localforage";
|
|||||||
|
|
||||||
import { runPromptSource, type RawPrompt } from "./prompt-source-runtime";
|
import { runPromptSource, type RawPrompt } from "./prompt-source-runtime";
|
||||||
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
|
import { usePromptSourceStore } from "@/stores/use-prompt-source-store";
|
||||||
|
import { usePromptStore, type PersonalPrompt } from "@/stores/use-prompt-store";
|
||||||
import type { PromptSource } from "./prompt-source-presets";
|
import type { PromptSource } from "./prompt-source-presets";
|
||||||
|
|
||||||
export type Prompt = RawPrompt & {
|
export type Prompt = RawPrompt & {
|
||||||
|
sourceId: string;
|
||||||
category: string;
|
category: string;
|
||||||
githubUrl: string;
|
githubUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ALL_PROMPTS_OPTION = "全部";
|
export const ALL_PROMPTS_OPTION = "全部";
|
||||||
|
export const PERSONAL_PROMPTS_CATEGORY = "我的提示词";
|
||||||
|
|
||||||
export type PromptListResponse = {
|
export type PromptListResponse = {
|
||||||
items: Prompt[];
|
items: Prompt[];
|
||||||
@@ -18,12 +21,34 @@ export type PromptListResponse = {
|
|||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PromptSourceStatus = {
|
||||||
|
sourceId: string;
|
||||||
|
count: number;
|
||||||
|
lastSuccessAt: string;
|
||||||
|
lastError: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromptSourceRefreshResult = PromptSourceStatus & {
|
||||||
|
sourceName: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromptSourceRefreshSummary = {
|
||||||
|
results: PromptSourceRefreshResult[];
|
||||||
|
total: number;
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SourceCache = PromptSourceStatus & {
|
||||||
|
items: Prompt[];
|
||||||
|
fetchedAt: number;
|
||||||
|
signature: string;
|
||||||
|
};
|
||||||
|
|
||||||
const cacheTtlMs = 1000 * 60 * 60;
|
const cacheTtlMs = 1000 * 60 * 60;
|
||||||
const promptCacheStore = localforage.createInstance({ name: "infinite-canvas", storeName: "prompt_cache" });
|
const promptCacheStore = localforage.createInstance({ name: "infinite-canvas", storeName: "prompt_cache" });
|
||||||
|
const loadingSources = new Map<string, Promise<PromptSourceRefreshResult>>();
|
||||||
type SourceCache = { items: Prompt[]; fetchedAt: number; signature: string };
|
|
||||||
|
|
||||||
const loadingSources = new Map<string, Promise<Prompt[]>>();
|
|
||||||
|
|
||||||
function enabledSources() {
|
function enabledSources() {
|
||||||
return usePromptSourceStore.getState().sources.filter((source) => source.enabled);
|
return usePromptSourceStore.getState().sources.filter((source) => source.enabled);
|
||||||
@@ -33,41 +58,84 @@ function cacheKey(sourceId: string) {
|
|||||||
return `prompt-source:${sourceId}`;
|
return `prompt-source:${sourceId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cheap stable signature of a source so cached prompts invalidate when the script or name changes. */
|
|
||||||
function sourceSignature(source: PromptSource) {
|
function sourceSignature(source: PromptSource) {
|
||||||
const value = `${source.name}\n${source.githubUrl}\n${source.script}`;
|
const value = `${source.name}\n${source.url}\n${source.homepage}`;
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < value.length; i += 1) {
|
for (let i = 0; i < value.length; i += 1) hash = (hash * 31 + value.charCodeAt(i)) | 0;
|
||||||
hash = (hash * 31 + value.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
return `${value.length}:${hash}`;
|
return `${value.length}:${hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function withSourceMeta(source: PromptSource, items: RawPrompt[]): Prompt[] {
|
function withSourceMeta(source: PromptSource, items: RawPrompt[]): Prompt[] {
|
||||||
return items.map((item) => ({ ...item, category: source.name, githubUrl: source.githubUrl }));
|
return items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
description: item.description || "",
|
||||||
|
referenceImageUrls: Array.isArray(item.referenceImageUrls) ? item.referenceImageUrls : [],
|
||||||
|
sourceId: source.id,
|
||||||
|
category: source.name,
|
||||||
|
githubUrl: item.sourceUrl || source.homepage,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSource(source: PromptSource): Promise<Prompt[]> {
|
export function personalPromptToPrompt(item: PersonalPrompt): Prompt {
|
||||||
const items = await runPromptSource(source.script);
|
return {
|
||||||
const prompts = withSourceMeta(source, items);
|
...item,
|
||||||
await promptCacheStore.setItem<SourceCache>(cacheKey(source.id), { items: prompts, fetchedAt: Date.now(), signature: sourceSignature(source) });
|
coverUrl: item.coverUrl || item.referenceImageUrls[0] || "",
|
||||||
return prompts;
|
sourceId: "personal",
|
||||||
|
category: PERSONAL_PROMPTS_CATEGORY,
|
||||||
|
githubUrl: "",
|
||||||
|
preview: "",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSourcePrompts(source: PromptSource, force = false): Promise<Prompt[]> {
|
async function readSourceCache(sourceId: string) {
|
||||||
const signature = sourceSignature(source);
|
return promptCacheStore.getItem<SourceCache>(cacheKey(sourceId));
|
||||||
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;
|
async function refreshSourceRecord(source: PromptSource): Promise<PromptSourceRefreshResult> {
|
||||||
|
const previous = await readSourceCache(source.id);
|
||||||
|
try {
|
||||||
|
const items = withSourceMeta(source, await runPromptSource(source));
|
||||||
|
const lastSuccessAt = new Date().toISOString();
|
||||||
|
const cache: SourceCache = { sourceId: source.id, items, count: items.length, fetchedAt: Date.now(), lastSuccessAt, lastError: "", signature: sourceSignature(source) };
|
||||||
|
await promptCacheStore.setItem(cacheKey(source.id), cache);
|
||||||
|
return { sourceId: source.id, sourceName: source.name, count: items.length, lastSuccessAt, lastError: "", success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
const cache: SourceCache = {
|
||||||
|
sourceId: source.id,
|
||||||
|
items: previous?.items || [],
|
||||||
|
count: previous?.items?.length || 0,
|
||||||
|
fetchedAt: previous?.fetchedAt || 0,
|
||||||
|
lastSuccessAt: previous?.lastSuccessAt || "",
|
||||||
|
lastError,
|
||||||
|
signature: previous?.signature || sourceSignature(source),
|
||||||
|
};
|
||||||
|
await promptCacheStore.setItem(cacheKey(source.id), cache);
|
||||||
|
return { sourceId: source.id, sourceName: source.name, count: cache.count, lastSuccessAt: cache.lastSuccessAt, lastError, success: false };
|
||||||
}
|
}
|
||||||
if (!force && loadingSources.has(source.id)) return loadingSources.get(source.id)!;
|
}
|
||||||
const loading = runSource(source).finally(() => loadingSources.delete(source.id));
|
|
||||||
|
function getOrStartRefresh(source: PromptSource) {
|
||||||
|
const current = loadingSources.get(source.id);
|
||||||
|
if (current) return current;
|
||||||
|
const loading = refreshSourceRecord(source).finally(() => loadingSources.delete(source.id));
|
||||||
loadingSources.set(source.id, loading);
|
loadingSources.set(source.id, loading);
|
||||||
return loading;
|
return loading;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Aggregate prompts across all enabled sources; a failing source is skipped so others still load. */
|
async function getSourcePrompts(source: PromptSource): Promise<Prompt[]> {
|
||||||
async function getAllPrompts(): Promise<Prompt[]> {
|
const cached = await readSourceCache(source.id);
|
||||||
|
if (cached) {
|
||||||
|
const stale = cached.signature !== sourceSignature(source) || Date.now() - cached.fetchedAt >= cacheTtlMs;
|
||||||
|
if (stale) void getOrStartRefresh(source).catch(() => undefined);
|
||||||
|
return withSourceMeta(source, cached.items);
|
||||||
|
}
|
||||||
|
const result = await getOrStartRefresh(source);
|
||||||
|
if (!result.success) throw new Error(result.lastError);
|
||||||
|
return (await readSourceCache(source.id))?.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllPrompts(includePersonal: boolean): Promise<Prompt[]> {
|
||||||
const settled = await Promise.all(
|
const settled = await Promise.all(
|
||||||
enabledSources().map(async (source) => {
|
enabledSources().map(async (source) => {
|
||||||
try {
|
try {
|
||||||
@@ -77,50 +145,76 @@ async function getAllPrompts(): Promise<Prompt[]> {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return settled.flat();
|
const personal = includePersonal ? usePromptStore.getState().prompts.map(personalPromptToPrompt) : [];
|
||||||
|
return [...personal, ...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 } = {}) {
|
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page = 1, pageSize = 20, includePersonal = true }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number; includePersonal?: boolean } = {}) {
|
||||||
const items = await getAllPrompts();
|
const items = await getAllPrompts(includePersonal);
|
||||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||||
const normalizedPage = Math.max(1, page);
|
const normalizedPage = Math.max(1, page);
|
||||||
const normalizedPageSize = Math.max(1, Math.min(100, pageSize));
|
const normalizedPageSize = Math.max(1, Math.min(100, pageSize));
|
||||||
const withoutTagFilter = filterPrompts(items, { keyword: normalizedKeyword, category, tags: [] });
|
const withoutTagFilter = filterPrompts(items, { keyword: normalizedKeyword, category, tags: [] });
|
||||||
const filtered = filterPrompts(items, { keyword: normalizedKeyword, category, tags: tag });
|
const filtered = filterPrompts(items, { keyword: normalizedKeyword, category, tags: tag });
|
||||||
|
const categories = enabledSources().map((source) => source.name);
|
||||||
|
if (includePersonal && usePromptStore.getState().prompts.length) categories.unshift(PERSONAL_PROMPTS_CATEGORY);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: filtered.slice((normalizedPage - 1) * normalizedPageSize, normalizedPage * normalizedPageSize),
|
items: filtered.slice((normalizedPage - 1) * normalizedPageSize, normalizedPage * normalizedPageSize),
|
||||||
tags: collectTags(withoutTagFilter),
|
tags: collectTags(withoutTagFilter),
|
||||||
categories: enabledSources().map((source) => source.name),
|
categories,
|
||||||
total: filtered.length,
|
total: filtered.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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): Promise<Prompt[]> {
|
||||||
export async function fetchSourcePrompts(sourceId: string, force = false): Promise<Prompt[]> {
|
|
||||||
const source = usePromptSourceStore.getState().sources.find((item) => item.id === sourceId);
|
const source = usePromptSourceStore.getState().sources.find((item) => item.id === sourceId);
|
||||||
if (!source) throw new Error("提示词来源不存在");
|
if (!source) throw new Error("提示词来源不存在");
|
||||||
return getSourcePrompts(source, force);
|
return getSourcePrompts(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Force refetch one source and refresh its cache; returns the fetched count. */
|
export async function refreshSource(sourceId: string): Promise<PromptSourceRefreshResult> {
|
||||||
export async function refreshSource(sourceId: string): Promise<number> {
|
const source = usePromptSourceStore.getState().sources.find((item) => item.id === sourceId);
|
||||||
const items = await fetchSourcePrompts(sourceId, true);
|
if (!source) throw new Error("提示词来源不存在");
|
||||||
return items.length;
|
const result = await getOrStartRefresh(source);
|
||||||
|
if (!result.success) throw new Error(result.lastError);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Force refetch every enabled source; returns the total prompt count. */
|
export async function refreshAllSources(): Promise<PromptSourceRefreshSummary> {
|
||||||
export async function refreshAllSources(): Promise<number> {
|
const results = await Promise.all(enabledSources().map(getOrStartRefresh));
|
||||||
const settled = await Promise.all(
|
return summarizeRefresh(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshDueSources(maxAgeMs: number): Promise<PromptSourceRefreshSummary> {
|
||||||
|
const sources = await Promise.all(
|
||||||
enabledSources().map(async (source) => {
|
enabledSources().map(async (source) => {
|
||||||
try {
|
const cached = await readSourceCache(source.id);
|
||||||
return await getSourcePrompts(source, true);
|
const lastSuccess = cached?.lastSuccessAt ? new Date(cached.lastSuccessAt).getTime() : 0;
|
||||||
} catch {
|
return !lastSuccess || Boolean(cached?.lastError) || Date.now() - lastSuccess >= maxAgeMs || cached?.signature !== sourceSignature(source) ? source : null;
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return settled.reduce((total, items) => total + items.length, 0);
|
const results = await Promise.all(sources.filter((source): source is PromptSource => Boolean(source)).map(getOrStartRefresh));
|
||||||
|
return summarizeRefresh(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPromptSourceStatuses(): Promise<Record<string, PromptSourceStatus>> {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
usePromptSourceStore.getState().sources.map(async (source) => {
|
||||||
|
const cache = await readSourceCache(source.id);
|
||||||
|
return [source.id, { sourceId: source.id, count: cache?.items?.length || 0, lastSuccessAt: cache?.lastSuccessAt || "", lastError: cache?.lastError || "" }] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRefresh(results: PromptSourceRefreshResult[]): PromptSourceRefreshSummary {
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
total: results.reduce((total, item) => total + item.count, 0),
|
||||||
|
successCount: results.filter((item) => item.success).length,
|
||||||
|
failureCount: results.filter((item) => !item.success).length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterPrompts(items: Prompt[], options: { keyword: string; category: string; tags: string[] }) {
|
function filterPrompts(items: Prompt[], options: { keyword: string; category: string; tags: string[] }) {
|
||||||
@@ -128,7 +222,7 @@ function filterPrompts(items: Prompt[], options: { keyword: string; category: st
|
|||||||
if (isActiveOption(options.category) && item.category !== options.category) return false;
|
if (isActiveOption(options.category) && item.category !== options.category) return false;
|
||||||
if (options.tags.length && !options.tags.some((tag) => item.tags.includes(tag))) return false;
|
if (options.tags.length && !options.tags.some((tag) => item.tags.includes(tag))) return false;
|
||||||
if (!options.keyword) return true;
|
if (!options.keyword) return true;
|
||||||
return [item.title, item.prompt, item.category, ...item.tags].join(" ").toLowerCase().includes(options.keyword);
|
return [item.title, item.prompt, item.description, item.category, ...item.tags].join(" ").toLowerCase().includes(options.keyword);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +231,7 @@ function collectTags(items: Prompt[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isActiveOption(value: string) {
|
function isActiveOption(value: string) {
|
||||||
return value && value !== "全部" && value !== "all";
|
return value && value !== ALL_PROMPTS_OPTION && value !== "all";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatPromptDate(value: string) {
|
export function formatPromptDate(value: string) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type PromptSourceSchedule = {
|
|||||||
lastFetchedAt: string;
|
lastFetchedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROMPT_SOURCE_STORE_KEY = "infinite-canvas:prompt_source_store";
|
const PROMPT_SOURCE_STORE_KEY = "infinite-canvas:prompt_source_store_v2";
|
||||||
|
|
||||||
const defaultSchedule: PromptSourceSchedule = {
|
const defaultSchedule: PromptSourceSchedule = {
|
||||||
intervalMinutes: 30,
|
intervalMinutes: 30,
|
||||||
@@ -38,13 +38,14 @@ export const usePromptSourceStore = create<PromptSourceStore>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
sources: DEFAULT_PROMPT_SOURCES,
|
sources: DEFAULT_PROMPT_SOURCES,
|
||||||
schedule: defaultSchedule,
|
schedule: defaultSchedule,
|
||||||
addSource: () => {
|
addSource: () => createPromptSource(),
|
||||||
const source = createPromptSource();
|
saveSource: (source) =>
|
||||||
set((state) => ({ sources: [...state.sources, source] }));
|
set((state) => ({
|
||||||
return source;
|
sources: state.sources.some((item) => item.id === source.id)
|
||||||
},
|
? state.sources.map((item) => (item.id === source.id && !item.builtIn ? createPromptSource(source) : item))
|
||||||
saveSource: (source) => set((state) => ({ sources: state.sources.map((item) => (item.id === source.id ? source : item)) })),
|
: [...state.sources, createPromptSource(source)],
|
||||||
removeSource: (id) => set((state) => ({ sources: state.sources.filter((item) => item.id !== id) })),
|
})),
|
||||||
|
removeSource: (id) => set((state) => ({ sources: state.sources.filter((item) => item.id !== id || item.builtIn) })),
|
||||||
toggleSource: (id, enabled) => set((state) => ({ sources: state.sources.map((item) => (item.id === id ? { ...item, enabled } : item)) })),
|
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 } })),
|
updateSchedule: (key, value) => set((state) => ({ schedule: { ...state.schedule, [key]: value } })),
|
||||||
}),
|
}),
|
||||||
@@ -53,12 +54,11 @@ export const usePromptSourceStore = create<PromptSourceStore>()(
|
|||||||
partialize: (state) => ({ sources: state.sources, schedule: state.schedule }),
|
partialize: (state) => ({ sources: state.sources, schedule: state.schedule }),
|
||||||
merge: (persisted, current) => {
|
merge: (persisted, current) => {
|
||||||
const persistedState = (persisted || {}) as Partial<PromptSourceStore>;
|
const persistedState = (persisted || {}) as Partial<PromptSourceStore>;
|
||||||
const sources = Array.isArray(persistedState.sources) && persistedState.sources.length ? persistedState.sources.map((item) => createPromptSource(item)) : DEFAULT_PROMPT_SOURCES;
|
const savedSources = Array.isArray(persistedState.sources) ? persistedState.sources : [];
|
||||||
return {
|
const enabledById = new Map(savedSources.map((source) => [source.id, source.enabled]));
|
||||||
...current,
|
const builtIn = DEFAULT_PROMPT_SOURCES.map((source) => ({ ...source, enabled: enabledById.get(source.id) ?? source.enabled }));
|
||||||
sources,
|
const custom = savedSources.filter((source) => !source.builtIn).map((source) => createPromptSource(source));
|
||||||
schedule: { ...defaultSchedule, ...(persistedState.schedule || {}) },
|
return { ...current, sources: [...builtIn, ...custom], schedule: { ...defaultSchedule, ...(persistedState.schedule || {}) } };
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
import { localForageStorage } from "@/lib/localforage-storage";
|
||||||
|
|
||||||
|
export type PersonalPrompt = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
prompt: string;
|
||||||
|
description: string;
|
||||||
|
coverUrl: string;
|
||||||
|
referenceImageUrls: string[];
|
||||||
|
tags: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
imageMode?: string;
|
||||||
|
imageModel?: string;
|
||||||
|
imageSize?: string;
|
||||||
|
imageCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PersonalPromptInput = Omit<PersonalPrompt, "id" | "createdAt" | "updatedAt">;
|
||||||
|
|
||||||
|
type PromptStore = {
|
||||||
|
hydrated: boolean;
|
||||||
|
prompts: PersonalPrompt[];
|
||||||
|
addPrompt: (prompt: PersonalPromptInput) => string;
|
||||||
|
updatePrompt: (id: string, prompt: PersonalPromptInput) => void;
|
||||||
|
removePrompt: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePromptStore = create<PromptStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
hydrated: false,
|
||||||
|
prompts: [],
|
||||||
|
addPrompt: (prompt) => {
|
||||||
|
const id = nanoid();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
set((state) => ({ prompts: [{ ...prompt, id, createdAt: now, updatedAt: now }, ...state.prompts] }));
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
updatePrompt: (id, prompt) => set((state) => ({ prompts: state.prompts.map((item) => (item.id === id ? { ...item, ...prompt, updatedAt: new Date().toISOString() } : item)) })),
|
||||||
|
removePrompt: (id) => set((state) => ({ prompts: state.prompts.filter((item) => item.id !== id) })),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "infinite-canvas:prompt_store",
|
||||||
|
storage: createJSONStorage(() => localForageStorage),
|
||||||
|
onRehydrateStorage: () => () => usePromptStore.setState({ hydrated: true }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user