feat(prompts): unify sources and add personal library

This commit is contained in:
yu
2026-07-20 23:16:46 +08:00
parent c81bb8b651
commit 49851f2b5f
19 changed files with 736 additions and 539 deletions
+10 -10
View File
@@ -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 输出仅显示在事件所属线程,运行中不能新建、恢复、删除或再次发送任务。
+47 -10
View File
@@ -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;
}
}
+3 -3
View File
@@ -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,
+11 -8
View File
@@ -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;
} }
+3 -3
View File
@@ -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>
+98 -73
View File
@@ -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 };
}
+17 -98
View File
@@ -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-PromptsREADME.zh-CN.md 里每个 ### 段落一条提示词。
const base = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main";
const markdown = await fetchText(\`\${base}/README.zh-CN.md\`);
const items = [];
for (const block of splitSections(markdown, "### ")) {
const title = firstMatch(block, /^###\\s+(.+)$/m).trim();
const prompt = firstMatch(block, /- \\*\\*提示词文本:\\*\\*\\s*\\\`(.*?)\\\`/s).trim();
if (!title || !prompt) continue;
const images = extractImages(base, block);
items.push(makePrompt({ id: \`awesome-gpt4o-image-prompts-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags: ["gpt4o"], preview: markdownPreview(images) }));
}
return items;`;
function youMindScript(base: string, idPrefix: string, modelTag: string) {
return `// YouMind 系列:README_zh.md 里 "### No.N: 标题" + "#### ...提示词" 代码块。
const base = "${base}";
const idPrefix = "${idPrefix}";
const modelTag = "${modelTag}";
const markdown = await fetchText(\`\${base}/README_zh.md\`);
const items = [];
for (const block of splitSections(markdown, "### ")) {
const title = firstMatch(block, /^###\\s+No\\.\\s*\\d+:\\s*(.+)$/m).trim();
const prompt = firstMatch(block, /#### .*?提示词\\s*\\r?\\n\\s*\\\`\\\`\\\`[\\w-]*\\r?\\n(.*?)\\r?\\n\\\`\\\`\\\`/s).trim();
if (!title || !prompt) continue;
const images = extractImages(base, block);
const [, prefix] = title.match(/^(.+?) - /) || [];
const tags = [modelTag, ...tagsFromHeading(prefix || "")];
items.push(makePrompt({ id: \`\${idPrefix}-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags, preview: markdownPreview(images) }));
}
return items;`;
}
const davidWuGptImage2Script = `// davidwuprompts.json 结构化数据,逐条转换。
const base = "https://raw.githubusercontent.com/davidwuw0811-boop/awesome-gpt-image2-prompts/main";
const data = await fetchJson(\`\${base}/prompts.json\`);
const items = [];
data.forEach((item, index) => {
const title = (item.title_cn || item.title_en || "").trim();
const prompt = (item.prompt || "").trim();
if (!title || !prompt) return;
const image = absoluteUrl(base, item.image || "");
const tags = splitTags([item.category_cn, item.category, item.author, item.source].filter(Boolean).join("/"), /\\//);
if (item.needs_ref) tags.push("需要参考图");
const preview = [item.title_en, item.note, image ? \`![](\${image})\` : ""].filter(Boolean).join("\\n\\n");
items.push(makePrompt({ id: \`davidwu-gpt-image2-prompts-\${leftPad(item.id || index + 1)}\`, title, prompt, coverUrl: image, tags, preview }));
});
return items;`;
export const DEFAULT_PROMPT_SOURCES: PromptSource[] = [ 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;`;
+91 -138
View File
@@ -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) => `![](${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
View File
@@ -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) {
+14 -14
View File
@@ -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 || {}) } };
};
}, },
}, },
), ),
+53
View File
@@ -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 }),
},
),
);