diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea3bcd..4f146be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,17 +2,17 @@ ## Unreleased ++ [新增] 提示词来源新增 Banana Prompt Quicker,并支持添加自定义标准 JSON 来源。 ++ [新增] 提示词中心新增「我的提示词」,支持本地管理、从公共库保存,并可在画布提示词库中直接使用。 + [新增] Agent 新增统一生成任务状态查询,支持查看画布、生图工作台和视频工作台任务进度。 -+ [新增] 左侧画布面板新增「提示词库」Tab,按来源折叠分组。 -+ [新增] 提示词来源增加自定义脚本抓取功能、支持新增来源。 -+ [优化] Agent 对话通过右侧主题气泡与左侧开放式回复布局区分用户和 AI 消息。 -+ [优化] 画布节点生成完成后,输入框保留提示词内容,不再自动清空。 -+ [修复] 本地 Agent 请求与发起标签页强绑定,校验工具结果归属,并在页面关闭后回退到最近聚焦页面。 -+ [修复] 多标签页共享 Codex 会话改由 Agent 广播同步,按线程过滤消息和状态,并禁止运行中切换会话。 -+ [修复] 本地 Agent 统一广播 Codex 运行状态,新连接标签页会同步禁用发送,并明确区分工具完成与本轮完成。 -+ [修复] 本地 Agent 上传的图片附件可创建为画布图片节点并连接生成流程,不再出现参考图空节点。 -+ [修复] Agent 对话切换时自动定位到最新消息,并在向上浏览记录时提供快速回到底部按钮。 -+ [修复] 处于「提示词库/资产」Tab 时拖动画布元素卡顿,面板内容不再随节点更新重渲染。 ++ [调整] 内置提示词来源改为读取 Image Prompts 统一 JSON 数据,不再由画布端分别解析。 ++ [调整] 提示词来源配置保留卡片式交互,并展示来源数量、同步状态和上次成功时间。 ++ [优化] 提示词来源支持独立缓存和更新,更新失败时保留上一次成功内容。 ++ [优化] Agent 对话区分用户与 AI 消息,并优化会话切换和回到最新消息的交互。 ++ [优化] 画布生成完成后保留提示词输入,并改善提示词库和资产面板的交互性能。 ++ [修复] 本地 Agent 完善多标签页请求隔离、结果归属、焦点回退和 Codex 会话状态同步。 ++ [修复] 本地 Agent 上传的图片附件可正确创建画布图片节点并连接生成流程。 ++ [修复] 画布提示词库支持跨来源搜索,插入节点时保留提示词标题。 ## v0.9.0 - 2026-07-17 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 4c16198..4725a19 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;网页面板发起的整个 Codex turn 应固定操作发起页面,即使中途聚焦另一标签页也不能切换目标;关闭当前页面后应回退到最近聚焦且仍连接的页面,其他页面回传同一请求结果应被拒绝。 - 本地 Agent 多标签页会话同步:所有标签页共享同一个站点级 Codex 活跃线程;任一页面发送消息、新建、恢复或删除会话后,其他页面应同步活跃线程和聊天记录;Agent 输出仅显示在事件所属线程,运行中不能新建、恢复、删除或再次发送任务。 diff --git a/web/src/components/canvas/canvas-side-panel.tsx b/web/src/components/canvas/canvas-side-panel.tsx index c60d5ec..2e8ac93 100644 --- a/web/src/components/canvas/canvas-side-panel.tsx +++ b/web/src/components/canvas/canvas-side-panel.tsx @@ -9,11 +9,12 @@ import { exportCanvasNodes } from "@/lib/canvas/canvas-export"; import { getNodeDefinition } from "@/lib/canvas/node-registry"; import { cn } from "@/lib/utils"; 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 { uploadImage } from "@/services/image-storage"; import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-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 { useThemeStore } from "@/stores/use-theme-store"; 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 { message } = App.useApp(); const sources = usePromptSourceStore((state) => state.sources); + const personalPrompts = usePromptStore((state) => state.prompts); const enabledSources = useMemo(() => sources.filter((source) => source.enabled), [sources]); const [keyword, setKeyword] = useState(""); - const [expanded, setExpanded] = useState>(() => (enabledSources[0] ? { [enabledSources[0].id]: true } : {})); + const [expanded, setExpanded] = useState>({ personal: true }); const [detail, setDetail] = useState(null); const copyPrompt = async (prompt: string) => { @@ -463,8 +465,18 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o } placeholder="搜索提示词" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
- {enabledSources.length ? ( -
+
+ setExpanded((prev) => ({ ...prev, personal: !prev.personal }))} + onInsert={onInsert} + onView={setDetail} + /> + {enabledSources.length ? ( + <> {enabledSources.map((source) => ( ))} -
- ) : ( - - )} + + ) : personalPrompts.length === 0 ? : null} +
setDetail(null)} onCopy={(prompt) => void copyPrompt(prompt)} /> ); }); +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 ( +
+ + {showResults ? ( +
+ {filtered.map((item) => onInsert({ kind: "text", content: item.prompt, title: item.title })} onView={() => onView(item)} />)} + {!filtered.length ?
{keyword.trim() ? "无匹配提示词" : "还没有保存提示词"}
: null} +
+ ) : null} +
+ ); +} + function PromptSourceGroup({ sourceId, sourceName, @@ -508,7 +544,8 @@ function PromptSourceGroup({ 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 items = query.data || []; @@ -522,12 +559,12 @@ function PromptSourceGroup({ return (
- {open ? ( + {showResults ? (
{query.isLoading ? (
diff --git a/web/src/components/layout/config-prompt-sources.tsx b/web/src/components/layout/config-prompt-sources.tsx index 66efc81..a7c1769 100644 --- a/web/src/components/layout/config-prompt-sources.tsx +++ b/web/src/components/layout/config-prompt-sources.tsx @@ -1,16 +1,18 @@ -import { App, Button, Select, Switch } from "antd"; -import { useQueryClient } from "@tanstack/react-query"; +import { App, Button, Select, Switch, Tag } from "antd"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { useState } from "react"; import { PromptSourceEditorDrawer } from "./prompt-source-editor-drawer"; import { PromptSourceContentModal } from "./prompt-source-content-modal"; -import { refreshAllSources, refreshSource } from "@/services/api/prompts"; +import { fetchPromptSourceStatuses, refreshAllSources, refreshSource } from "@/services/api/prompts"; import { PROMPT_SOURCE_INTERVAL_OPTIONS, usePromptSourceStore } from "@/stores/use-prompt-source-store"; import type { PromptSource } from "@/services/api/prompt-source-presets"; +const STATUS_QUERY_KEY = ["prompt-source-statuses"]; + export function ConfigPromptSources() { - const { message } = App.useApp(); + const { message, modal } = App.useApp(); const queryClient = useQueryClient(); const sources = usePromptSourceStore((state) => state.sources); const schedule = usePromptSourceStore((state) => state.schedule); @@ -19,20 +21,20 @@ export function ConfigPromptSources() { const removeSource = usePromptSourceStore((state) => state.removeSource); const toggleSource = usePromptSourceStore((state) => state.toggleSource); const updateSchedule = usePromptSourceStore((state) => state.updateSchedule); + const statusQuery = useQuery({ queryKey: STATUS_QUERY_KEY, queryFn: fetchPromptSourceStatuses }); - const [editingId, setEditingId] = useState(""); + const [editingSource, setEditingSource] = useState(null); const [viewingId, setViewingId] = useState(""); const [refreshingId, setRefreshingId] = useState(""); const [refreshingAll, setRefreshingAll] = useState(false); - - const editingSource = sources.find((item) => item.id === editingId) || null; const viewingSource = sources.find((item) => item.id === viewingId) || null; - const invalidatePrompts = () => queryClient.invalidateQueries({ queryKey: ["prompts"] }); - - const handleAdd = () => { - const source = addSource(); - setEditingId(source.id); + const invalidatePrompts = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["prompts"] }), + queryClient.invalidateQueries({ queryKey: ["side-panel-prompts"] }), + queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY }), + ]); }; const handleSave = (source: PromptSource) => { @@ -41,22 +43,28 @@ export function ConfigPromptSources() { }; const handleDelete = (source: PromptSource) => { - if (sources.length <= 1) { - message.warning("至少保留一个来源"); - return; - } - removeSource(source.id); - void invalidatePrompts(); + modal.confirm({ + title: `删除「${source.name}」?`, + content: "来源配置会被移除,已经保存到我的提示词的内容不受影响。", + okText: "删除", + okButtonProps: { danger: true }, + cancelText: "取消", + onOk: async () => { + removeSource(source.id); + await invalidatePrompts(); + }, + }); }; const handleRefreshOne = async (source: PromptSource) => { setRefreshingId(source.id); try { - const count = await refreshSource(source.id); + const result = await refreshSource(source.id); await invalidatePrompts(); - message.success(`「${source.name}」已拉取 ${count} 条`); + message.success(`「${source.name}」已更新 ${result.count} 条`); } catch (error) { - message.error(error instanceof Error ? error.message : "拉取失败"); + await queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY }); + message.error(error instanceof Error ? error.message : "更新失败,已保留旧缓存"); } finally { setRefreshingId(""); } @@ -65,12 +73,13 @@ export function ConfigPromptSources() { const handleRefreshAll = async () => { setRefreshingAll(true); try { - const count = await refreshAllSources(); + const result = await refreshAllSources(); updateSchedule("lastFetchedAt", new Date().toISOString()); await invalidatePrompts(); - message.success(`全部来源已拉取,共 ${count} 条`); + if (result.failureCount) message.warning(`更新完成:${result.successCount} 个成功,${result.failureCount} 个失败,失败来源已保留旧缓存`); + else message.success(`已更新 ${result.successCount} 个来源,共 ${result.total} 条`); } catch (error) { - message.error(error instanceof Error ? error.message : "拉取失败"); + message.error(error instanceof Error ? error.message : "更新失败"); } finally { setRefreshingAll(false); } @@ -78,40 +87,48 @@ export function ConfigPromptSources() { return (
-
-
每个来源是一段拉取脚本,可自定义、可查看内容;系统内置几个默认来源,你也可以本地新增。
-
- {sources.map((source) => ( -
-
- toggleSource(source.id, checked)} /> -
-
{source.name || "未命名来源"}
-
{source.githubUrl || "无 GitHub 地址"}
+ {sources.map((source) => { + const status = statusQuery.data?.[source.id]; + return ( +
+ { toggleSource(source.id, checked); void invalidatePrompts(); }} /> +
+
+ {source.name} + {source.builtIn ? 内置 : null} +
+
+ + {source.homepage || source.url} + + {status?.count ?? 0} 条 + {status?.lastError ? 失败 : status?.lastSuccessAt ? 正常 : 未同步} + {status?.lastSuccessAt ? `上次成功 ${formatTime(status.lastSuccessAt)}` : "尚未拉取"} +
+
+
+ + + {!source.builtIn ? : null} + {!source.builtIn ? : null}
-
- - - -
-
- ))} + ); + })}
-
+
定时拉取
@@ -126,12 +143,13 @@ export function ConfigPromptSources() {
开启周期后,页面打开期间会按周期自动拉取所有启用的来源。
- setEditingId("")} /> + setEditingSource(null)} /> setViewingId("")} />
); } 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" }); } diff --git a/web/src/components/layout/prompt-source-content-modal.tsx b/web/src/components/layout/prompt-source-content-modal.tsx index 8e6f0ac..6e0359d 100644 --- a/web/src/components/layout/prompt-source-content-modal.tsx +++ b/web/src/components/layout/prompt-source-content-modal.tsx @@ -55,7 +55,7 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
共 {items.length} 条
} diff --git a/web/src/components/layout/prompt-source-editor-drawer.tsx b/web/src/components/layout/prompt-source-editor-drawer.tsx index a83fc52..caa9a62 100644 --- a/web/src/components/layout/prompt-source-editor-drawer.tsx +++ b/web/src/components/layout/prompt-source-editor-drawer.tsx @@ -1,16 +1,10 @@ -import { javascript } from "@codemirror/lang-javascript"; -import CodeMirror from "@uiw/react-codemirror"; -import { Button, Drawer, Input, Space } from "antd"; +import { App, Button, Drawer, Input, Space, Switch } from "antd"; import { useEffect, useState } from "react"; -import { PROMPT_SOURCE_VARIABLES } from "@/services/api/prompt-source-runtime"; -import { PROMPT_SOURCE_TEMPLATE, type PromptSource } from "@/services/api/prompt-source-presets"; - -function isDarkMode() { - return typeof document !== "undefined" && document.documentElement.classList.contains("dark"); -} +import type { PromptSource } from "@/services/api/prompt-source-presets"; 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(source); useEffect(() => { @@ -22,15 +16,20 @@ export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { op const patch = (value: Partial) => setDraft((current) => (current ? { ...current, ...value } : current)); 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(); }; return ( } > -
+
-
- -
-
-
拉取脚本
-
脚本是一段异步函数体,直接使用下方变量,最后 return 一个提示词数组(每条至少含 title 和 prompt)。
+ +
+ 启用来源 + patch({ enabled })} />
- -
- -
- -
- 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" - /> +
+
JSON 格式
+
{`[
+  {
+    "id": "product-photo-1",
+    "title": "白底商品图",
+    "prompt": "生成专业白底商品摄影图",
+    "description": "",
+    "coverUrl": "",
+    "referenceImageUrls": [],
+    "tags": ["商品", "摄影"]
+  }
+]`}
); } + +function isHttpUrl(value: string) { + try { + return ["http:", "https:"].includes(new URL(value).protocol); + } catch { + return false; + } +} diff --git a/web/src/components/prompts/prompt-card.tsx b/web/src/components/prompts/prompt-card.tsx index ce8c011..f29d9d8 100644 --- a/web/src/components/prompts/prompt-card.tsx +++ b/web/src/components/prompts/prompt-card.tsx @@ -1,4 +1,4 @@ -import { Copy } from "lucide-react"; +import { Copy, FileText } from "lucide-react"; import type { ReactNode } from "react"; import { Button, Card, Tag } from "antd"; @@ -28,7 +28,7 @@ export function PromptCard({ styles={{ body: { padding: 0 } }} cover={ } > @@ -38,7 +38,7 @@ export function PromptCard({

{item.title}

{formatPromptDate(item.updatedAt)}
-

{item.prompt}

+

{item.description || item.prompt}

{item.tags.map((tag) => ( diff --git a/web/src/components/prompts/prompt-select-dialog.tsx b/web/src/components/prompts/prompt-select-dialog.tsx index 6f7b4fc..4edbf30 100644 --- a/web/src/components/prompts/prompt-select-dialog.tsx +++ b/web/src/components/prompts/prompt-select-dialog.tsx @@ -12,7 +12,7 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo const [keyword, setKeyword] = useState(""); const [selectedTags, setSelectedTags] = useState([]); 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) => { if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]); setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag])); diff --git a/web/src/components/prompts/use-prompt-list.ts b/web/src/components/prompts/use-prompt-list.ts index b5b8f20..58270f1 100644 --- a/web/src/components/prompts/use-prompt-list.ts +++ b/web/src/components/prompts/use-prompt-list.ts @@ -5,10 +5,10 @@ import { ALL_PROMPTS_OPTION, fetchPrompts } from "@/services/api/prompts"; 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({ - queryKey: ["prompts", keyword, tags, category], - queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }), + queryKey: ["prompts", keyword, tags, category, includePersonal], + queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE, includePersonal }), initialPageParam: 1, getNextPageParam: (lastPage, pages) => (pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined), enabled, diff --git a/web/src/hooks/use-prompt-source-scheduler.ts b/web/src/hooks/use-prompt-source-scheduler.ts index 0ddd705..3087d6f 100644 --- a/web/src/hooks/use-prompt-source-scheduler.ts +++ b/web/src/hooks/use-prompt-source-scheduler.ts @@ -1,12 +1,12 @@ import { useEffect } from "react"; 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"; 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() { const queryClient = useQueryClient(); const intervalMinutes = usePromptSourceStore((state) => state.schedule.intervalMinutes); @@ -16,16 +16,19 @@ export function usePromptSourceScheduler() { let running = false; const tick = async () => { if (running) return; - const { schedule, updateSchedule } = usePromptSourceStore.getState(); - const last = schedule.lastFetchedAt ? new Date(schedule.lastFetchedAt).getTime() : 0; - if (Date.now() - last < intervalMinutes * 60_000) return; + const { updateSchedule } = usePromptSourceStore.getState(); running = true; try { - await refreshAllSources(); + const result = await refreshDueSources(intervalMinutes * 60_000); + if (!result.results.length) return; 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 { - // 拉取失败时静默重试,等待下一个检查周期。 + // 单个来源的错误已写入来源状态,下一个检查周期会继续尝试。 } finally { running = false; } diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 97963e5..2af172d 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -2591,11 +2591,11 @@ function InfiniteCanvasPage() { ); 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 node = { ...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]); @@ -2608,7 +2608,7 @@ function InfiniteCanvasPage() { const handleAssetInsert = useCallback( (payload: InsertAssetPayload) => { if (payload.kind === "text") { - insertAssistantText(payload.content); + insertAssistantText(payload.content, payload.title); } else if (payload.kind === "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); diff --git a/web/src/pages/prompts/components/my-prompt-editor-dialog.tsx b/web/src/pages/prompts/components/my-prompt-editor-dialog.tsx new file mode 100644 index 0000000..7055234 --- /dev/null +++ b/web/src/pages/prompts/components/my-prompt-editor-dialog.tsx @@ -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(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) => 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 ( + + + + + } + > +
+ + + +
+ + +
+ +
+
+ ); +} + +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))); +} diff --git a/web/src/pages/prompts/components/prompt-detail-dialog.tsx b/web/src/pages/prompts/components/prompt-detail-dialog.tsx index 7f5c396..1199d31 100644 --- a/web/src/pages/prompts/components/prompt-detail-dialog.tsx +++ b/web/src/pages/prompts/components/prompt-detail-dialog.tsx @@ -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 { 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 ( <> @@ -11,7 +11,8 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p <>
- {prompt.title} + {prompt.coverUrl ? {prompt.title} :
} + {prompt.referenceImageUrls.length > 1 ?
{prompt.referenceImageUrls.filter((url) => url !== prompt.coverUrl).slice(0, 6).map((url) => )}
: null} {prompt.preview ?
{prompt.preview}
: null}
@@ -22,10 +23,9 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { p ))}
+ {prompt.description ?

{prompt.description}

: null}

{prompt.prompt}

-
- 创建:{formatPromptDate(prompt.createdAt)} · 更新:{formatPromptDate(prompt.updatedAt)} -
+ {prompt.createdAt || prompt.updatedAt ?
{prompt.createdAt ? `创建:${formatPromptDate(prompt.createdAt)}` : null}{prompt.createdAt && prompt.updatedAt ? " · " : null}{prompt.updatedAt ? `更新:${formatPromptDate(prompt.updatedAt)}` : null}
: null} ) : null} + {onSavePrompt ? ( + + ) : null}
diff --git a/web/src/pages/prompts/index.tsx b/web/src/pages/prompts/index.tsx index ce6c595..9efaf08 100644 --- a/web/src/pages/prompts/index.tsx +++ b/web/src/pages/prompts/index.tsx @@ -1,29 +1,41 @@ -import { FolderPlus, Search } from "lucide-react"; -import { type UIEvent, useEffect, useState } from "react"; -import { App, Button, Empty, Input, Spin, Tag } from "antd"; +import { BookmarkPlus, FolderPlus, Pencil, Plus, Search, Trash2 } from "lucide-react"; +import { type ReactNode, type UIEvent, useEffect, useMemo, useState } from "react"; +import { App, Button, Empty, Input, Popconfirm, Space, Spin, Tabs, Tag, Tooltip } from "antd"; import { PromptCard } from "@/components/prompts/prompt-card"; import { usePromptList } from "@/components/prompts/use-prompt-list"; +import { MyPromptEditorDialog } from "./components/my-prompt-editor-dialog"; import { PromptDetailDialog } from "./components/prompt-detail-dialog"; import { useCopyText } from "@/hooks/use-copy-text"; import { cn } from "@/lib/utils"; 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() { const { message } = App.useApp(); + const [activeTab, setActiveTab] = useState("library"); const [titleKeyword, setTitleKeyword] = useState(""); const [selectedTags, setSelectedTags] = useState([]); const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION); const [selectedPrompt, setSelectedPrompt] = useState(null); + const [editorOpen, setEditorOpen] = useState(false); + const [editingPrompt, setEditingPrompt] = useState(null); 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 { 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(() => { - if (query.isError) { - message.error(query.error instanceof Error ? query.error.message : "获取提示词失败"); - } + if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败"); }, [message, query.error, query.isError]); const toggleTag = (tag: string) => { @@ -36,91 +48,104 @@ export default function PromptsPage() { message.success("已加入我的资产"); }; - const handleListScroll = (event: UIEvent) => { - const target = event.currentTarget; - if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) { - void query.fetchNextPage(); - } + const saveToMyPrompts = (item: Prompt) => { + addPrompt(toPersonalInput(item)); + message.success("已保存到我的提示词"); }; + 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) => { + 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 (
-
-
-
-

提示词中心

-

共 {totalPrompts} 条提示词,按标题、标签与分类快速查找灵感。

-
- {query.isLoading ? ( -
- +
+
+
+
+

提示词中心

+

当前共 {visibleCount} 条提示词

- ) : null} - {!query.isLoading ? ( - <> -
- } value={titleKeyword} placeholder="按标题查询" onChange={(event) => setTitleKeyword(event.target.value)} /> -
-
-
-
分类
-
- {promptCategoryOptions.map((category) => ( - setSelectedCategory(category)}> - {category} - - ))} -
-
-
-
标签
-
- {promptTags.map((tag) => ( - toggleTag(tag)} - > - {tag} - - ))} -
+ {activeTab === "personal" ? ( + + ) : null} +
+ +
+ } value={titleKeyword} placeholder="搜索标题、内容或标签" onChange={(event) => setTitleKeyword(event.target.value)} /> +
+ {activeTab === "library" ? ( +
+ +
+
标签
+
+ {promptTags.map((tag) => { + const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag); + return toggleTag(tag)}>{tag}; + })}
- +
) : null}
- {!query.isLoading ? ( -
-
- {promptItems.map((item) => ( - setSelectedPrompt(item)} - onCopy={() => copyText(item.prompt, "提示词已复制")} - extraAction={ - - } - /> - ))} -
- {promptItems.length === 0 ? : null} -
- {query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null} -
-
+ {activeTab === "library" && query.isLoading ?
: null} + {activeTab === "library" && !query.isLoading ? ( + <>
- setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} /> + setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={selectedPrompt?.sourceId === "personal" ? undefined : savePromptAsset} onSavePrompt={selectedPrompt?.sourceId === "personal" ? undefined : saveToMyPrompts} /> + setEditorOpen(false)} />
); } + +function PromptFilter({ label, options, selected, onChange }: { label: string; options: string[]; selected: string; onChange: (value: string) => void }) { + return
{label}
{options.map((option) => onChange(option)}>{option})}
; +} + +function PromptGrid({ items, onOpen, onCopy, renderActions, emptyText }: { items: Prompt[]; onOpen: (item: Prompt) => void; onCopy: (item: Prompt) => void; renderActions: (item: Prompt) => ReactNode; emptyText: string }) { + return
{items.map((item) => onOpen(item)} onCopy={() => onCopy(item)} extraAction={renderActions(item)} />)}
{items.length === 0 ? : null}
; +} + +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 }; +} diff --git a/web/src/services/api/prompt-source-presets.ts b/web/src/services/api/prompt-source-presets.ts index ea30a30..0f6271a 100644 --- a/web/src/services/api/prompt-source-presets.ts +++ b/web/src/services/api/prompt-source-presets.ts @@ -3,116 +3,35 @@ import { nanoid } from "nanoid"; export type PromptSource = { id: string; name: string; - githubUrl: string; + url: string; + homepage: string; 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 { return { id: source?.id?.trim() || nanoid(), name: source?.name?.trim() || "新来源", - githubUrl: source?.githubUrl?.trim() || "", + url: source?.url?.trim() || "", + homepage: source?.homepage?.trim() || "", enabled: source?.enabled ?? true, - script: source?.script ?? "", + builtIn: source?.builtIn ?? false, }; } -const awesomeGptImageScript = `// awesome-gpt-image:从 README.zh-CN.md 解析,## 为标签分组,### 为单条提示词。 -const base = "https://raw.githubusercontent.com/ZeroLu/awesome-gpt-image/main"; -const markdown = await fetchText(\`\${base}/README.zh-CN.md\`); -const items = []; -for (const section of splitSections(markdown, "## ")) { - const tags = tagsFromHeading(firstMatch(section, /^##\\s+(.+)$/m)); - for (const block of splitSections(section, "### ")) { - const title = firstMatch(block, /^###\\s+(.+)$/m).replace(/\\[([^\\]]+)]\\([^)]+\\)/g, "$1").trim(); - const prompt = firstMatch(block, /\\*\\*提示词:\\*\\*\\s*\\r?\\n\\s*\\\`\\\`\\\`[\\w-]*\\r?\\n(.*?)\\r?\\n\\\`\\\`\\\`/s).trim(); - if (!title || !prompt) continue; - const images = extractImages(base, block); - items.push(makePrompt({ id: \`awesome-gpt-image-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags, preview: markdownPreview(images) })); - } -} -return items;`; - -const awesomeGpt4oImageScript = `// Awesome-GPT4o-Image-Prompts:README.zh-CN.md 里每个 ### 段落一条提示词。 -const base = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main"; -const markdown = await fetchText(\`\${base}/README.zh-CN.md\`); -const items = []; -for (const block of splitSections(markdown, "### ")) { - const title = firstMatch(block, /^###\\s+(.+)$/m).trim(); - const prompt = firstMatch(block, /- \\*\\*提示词文本:\\*\\*\\s*\\\`(.*?)\\\`/s).trim(); - if (!title || !prompt) continue; - const images = extractImages(base, block); - items.push(makePrompt({ id: \`awesome-gpt4o-image-prompts-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags: ["gpt4o"], preview: markdownPreview(images) })); -} -return items;`; - -function youMindScript(base: string, idPrefix: string, modelTag: string) { - return `// YouMind 系列:README_zh.md 里 "### No.N: 标题" + "#### ...提示词" 代码块。 -const base = "${base}"; -const idPrefix = "${idPrefix}"; -const modelTag = "${modelTag}"; -const markdown = await fetchText(\`\${base}/README_zh.md\`); -const items = []; -for (const block of splitSections(markdown, "### ")) { - const title = firstMatch(block, /^###\\s+No\\.\\s*\\d+:\\s*(.+)$/m).trim(); - const prompt = firstMatch(block, /#### .*?提示词\\s*\\r?\\n\\s*\\\`\\\`\\\`[\\w-]*\\r?\\n(.*?)\\r?\\n\\\`\\\`\\\`/s).trim(); - if (!title || !prompt) continue; - const images = extractImages(base, block); - const [, prefix] = title.match(/^(.+?) - /) || []; - const tags = [modelTag, ...tagsFromHeading(prefix || "")]; - items.push(makePrompt({ id: \`\${idPrefix}-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags, preview: markdownPreview(images) })); -} -return items;`; -} - -const davidWuGptImage2Script = `// davidwu:prompts.json 结构化数据,逐条转换。 -const base = "https://raw.githubusercontent.com/davidwuw0811-boop/awesome-gpt-image2-prompts/main"; -const data = await fetchJson(\`\${base}/prompts.json\`); -const items = []; -data.forEach((item, index) => { - const title = (item.title_cn || item.title_en || "").trim(); - const prompt = (item.prompt || "").trim(); - if (!title || !prompt) return; - const image = absoluteUrl(base, item.image || ""); - const tags = splitTags([item.category_cn, item.category, item.author, item.source].filter(Boolean).join("/"), /\\//); - if (item.needs_ref) tags.push("需要参考图"); - const preview = [item.title_en, item.note, image ? \`![](\${image})\` : ""].filter(Boolean).join("\\n\\n"); - items.push(makePrompt({ id: \`davidwu-gpt-image2-prompts-\${leftPad(item.id || index + 1)}\`, title, prompt, coverUrl: image, tags, preview })); -}); -return items;`; - export const DEFAULT_PROMPT_SOURCES: PromptSource[] = [ - { id: "davidwu-gpt-image2-prompts", name: "davidwu-gpt-image2-prompts", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", enabled: true, script: davidWuGptImage2Script }, - { id: "awesome-gpt-image", name: "awesome-gpt-image", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", enabled: true, script: awesomeGptImageScript }, - { id: "awesome-gpt4o-image-prompts", name: "awesome-gpt4o-image-prompts", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", enabled: true, script: awesomeGpt4oImageScript }, - { - id: "youmind-gpt-image-2", - name: "youmind-gpt-image-2", - githubUrl: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", - enabled: true, - script: youMindScript("https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main", "youmind-gpt-image-2", "gpt-image-2"), - }, - { - id: "youmind-nano-banana-pro", - name: "youmind-nano-banana-pro", - githubUrl: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", - enabled: true, - script: youMindScript("https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main", "youmind-nano-banana-pro", "nano-banana-pro"), - }, + registrySource("banana-prompt-quicker", "Banana Prompt Quicker", "https://glidea.github.io/banana-prompt-quicker/"), + registrySource("davidwu-gpt-image2-prompts", "DavidWu GPT Image 2", "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts"), + 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"), + registrySource("youmind-gpt-image-2", "YouMind GPT Image 2", "https://github.com/YouMind-OpenLab/awesome-gpt-image-2"), + registrySource("youmind-nano-banana-pro", "YouMind Nano Banana Pro", "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts"), ]; -/** Starter script inserted when a user creates a blank source. */ -export const PROMPT_SOURCE_TEMPLATE = `// 拉取远程列表并 return 一个提示词数组;每条至少含 title 和 prompt。 -// 可用辅助见右侧「可用变量」,例如 fetchText / splitSections / makePrompt。 -const base = "https://raw.githubusercontent.com/owner/repo/main"; -const markdown = await fetchText(\`\${base}/README.md\`); -const items = []; -for (const block of splitSections(markdown, "### ")) { - const title = firstMatch(block, /^###\\s+(.+)$/m).trim(); - const prompt = firstMatch(block, /\\\`\\\`\\\`[\\w-]*\\r?\\n(.*?)\\r?\\n\\\`\\\`\\\`/s).trim(); - if (!title || !prompt) continue; - const images = extractImages(base, block); - items.push(makePrompt({ id: \`my-source-\${leftPad(items.length + 1)}\`, title, prompt, coverUrl: images[0] || "", tags: [], preview: markdownPreview(images) })); +function registrySource(id: string, name: string, homepage: string): PromptSource { + return { id, name, url: `${PROMPT_REGISTRY_SOURCE_BASE}/${id}.json`, homepage, enabled: true, builtIn: true }; } -return items;`; diff --git a/web/src/services/api/prompt-source-runtime.ts b/web/src/services/api/prompt-source-runtime.ts index 0165166..1426505 100644 --- a/web/src/services/api/prompt-source-runtime.ts +++ b/web/src/services/api/prompt-source-runtime.ts @@ -1,165 +1,118 @@ -/** - * 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. - */ +import type { PromptSource } from "./prompt-source-presets"; export type RawPrompt = { id: string; title: string; - coverUrl: string; prompt: string; + description: string; + coverUrl: string; + referenceImageUrls: string[]; tags: string[]; preview: string; createdAt: string; updatedAt: string; + author?: string; + sourceUrl?: string; + imageMode?: string; + imageModel?: string; + imageSize?: string; + imageCount?: number; }; type RunOptions = { signal?: AbortSignal }; -async function fetchText(url: string) { - const response = await fetch(url, { cache: "no-store" }); - if (!response.ok) throw new Error(`${url} 拉取失败`); - return response.text(); +async function fetchSource(source: PromptSource, options?: RunOptions) { + const response = await fetch(source.url, { cache: "no-store", signal: options?.signal }); + if (!response.ok) throw new Error(`请求失败(${response.status})`); + return response.json(); } -async function fetchJson(url: string) { - return JSON.parse(await fetchText(url)) as T; -} - -/** Split markdown into blocks, each starting at a line that begins with `prefix` (e.g. "## " / "### "). */ -function splitSections(markdown: string, prefix: string) { - const blocks: string[] = []; - let current: string[] = []; - for (const line of markdown.split("\n")) { - if (line.startsWith(prefix) && current.length) { - blocks.push(current.join("\n")); - current = []; - } - current.push(line); +export async function runPromptSource(source: PromptSource, options?: RunOptions): Promise { + if (!source.url.trim()) throw new Error("JSON URL 不能为空"); + let data: unknown; + try { + data = await fetchSource(source, options); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + throw new Error(`「${source.name}」拉取失败:${error instanceof Error ? error.message : String(error)}`); } - blocks.push(current.join("\n")); - return blocks; + + const items = parseJsonSource(data, source); + if (source.builtIn && !items.length) throw new Error(`「${source.name}」未解析到有效提示词`); + return items; } -function firstMatch(value: string, pattern: RegExp) { - return pattern.exec(value)?.[1] || ""; +function parseJsonSource(data: unknown, source: PromptSource) { + if (!Array.isArray(data)) throw new Error(`「${source.name}」格式错误:根节点必须是数组`); + return normalizeItems(data, source); +} + +function normalizeItems(values: unknown[], source: PromptSource) { + const seen = new Set(); + 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 { + return value && typeof value === "object" ? (value as Record) : {}; +} + +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) { if (!path) return ""; - if (/^https?:\/\//i.test(path)) return path; - return `${baseUrl}/${path.replace(/^\.?\//, "")}`; -} - -function extractImages(baseUrl: string, markdown: string) { - return Array.from(markdown.matchAll(/!\[[^\]]*]\(([^)]+)\)/g), (match) => absoluteUrl(baseUrl, match[1])).filter(Boolean); -} - -function splitTags(value: string, pattern: RegExp) { - return value - .split(pattern) - .map((tag) => tag.trim().toLowerCase()) - .filter(Boolean); -} - -function tagsFromHeading(heading: string) { - return splitTags(heading.replace(/[^\p{L}\p{N}/&、与 ]/gu, ""), /\s*(?:\/|&|、|与)\s*/); -} - -function markdownPreview(images: string[]) { - return images - .filter(Boolean) - .map((image) => `![](${image})`) - .join("\n\n"); + try { + return new URL(path, baseUrl).toString(); + } catch { + return path; + } } function leftPad(value: number) { return String(value).padStart(4, "0"); } - -function makePrompt(input: { id: string; title: string; prompt: string; coverUrl?: string; tags?: string[]; preview?: string; createdAt?: string; updatedAt?: string }): RawPrompt { - return { - id: input.id, - title: input.title, - prompt: input.prompt, - coverUrl: input.coverUrl || "", - tags: input.tags || [], - preview: input.preview || "", - createdAt: input.createdAt || "", - updatedAt: input.updatedAt || "", - }; -} - -/** Run a prompt-source script and normalize its result into a deduped RawPrompt[]. */ -export async function runPromptSource(script: string, options?: RunOptions): Promise { - const body = script.trim(); - if (!body) throw new Error("提示词来源脚本为空"); - const runner = new Function( - "fetchText", - "fetchJson", - "splitSections", - "firstMatch", - "extractImages", - "absoluteUrl", - "tagsFromHeading", - "splitTags", - "markdownPreview", - "leftPad", - "makePrompt", - "signal", - `"use strict"; return (async () => {\n${body}\n})();`, - ) as (...args: unknown[]) => Promise; - let result: unknown; - try { - result = await runner(fetchText, fetchJson, splitSections, firstMatch, extractImages, absoluteUrl, tagsFromHeading, splitTags, markdownPreview, leftPad, makePrompt, options?.signal); - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") throw error; - const message = error instanceof Error ? error.message : String(error); - throw new Error(`提示词来源脚本执行失败:${message}`); - } - if (!Array.isArray(result)) throw new Error("提示词来源脚本需要 return 一个数组"); - const seen = new Set(); - const items: RawPrompt[] = []; - for (const raw of result) { - if (!raw || typeof raw !== "object") continue; - const record = raw as Record; - const title = String(record.title || "").trim(); - const prompt = String(record.prompt || "").trim(); - if (!title || !prompt) continue; - const id = String(record.id || "").trim() || `prompt-${leftPad(items.length + 1)}`; - if (seen.has(id)) continue; - seen.add(id); - items.push( - makePrompt({ - id, - title, - prompt, - coverUrl: String(record.coverUrl || ""), - tags: Array.isArray(record.tags) ? record.tags.map((tag) => String(tag)).filter(Boolean) : [], - preview: String(record.preview || ""), - createdAt: String(record.createdAt || ""), - updatedAt: String(record.updatedAt || ""), - }), - ); - } - return items; -} - -export type PromptSourceVariable = { name: string; type: string; desc: string }; - -/** Documentation surface shown in the prompt-source script editor. */ -export const PROMPT_SOURCE_VARIABLES: PromptSourceVariable[] = [ - { name: "fetchText", type: "function", desc: "fetchText(url) 拉取纯文本(README 等),失败抛错" }, - { name: "fetchJson", type: "function", desc: "fetchJson(url) 拉取并解析 JSON" }, - { name: "splitSections", type: "function", desc: "splitSections(markdown, prefix) 按标题前缀(如 '### ')切分成段落数组" }, - { name: "firstMatch", type: "function", desc: "firstMatch(text, /正则/) 返回第一个捕获组,未匹配返回空串" }, - { name: "extractImages", type: "function", desc: "extractImages(baseUrl, markdown) 提取 markdown 图片并补全为绝对地址" }, - { name: "absoluteUrl", type: "function", desc: "absoluteUrl(baseUrl, path) 把相对路径拼成绝对 URL" }, - { name: "tagsFromHeading", type: "function", desc: "tagsFromHeading(heading) 从标题按 / & 、与 切出标签(小写去重前)" }, - { name: "splitTags", type: "function", desc: "splitTags(value, /分隔符/) 切分标签并转小写去空" }, - { name: "markdownPreview", type: "function", desc: "markdownPreview(images) 把图片数组拼成 markdown 预览文本" }, - { name: "leftPad", type: "function", desc: "leftPad(n) 数字左补零到 4 位,用于生成有序 id" }, - { name: "makePrompt", type: "function", desc: "makePrompt({id,title,prompt,coverUrl,tags,preview}) 构造一条提示词;title 和 prompt 必填" }, - { name: "signal", type: "AbortSignal", desc: "取消信号,可透传给需要的请求" }, -]; diff --git a/web/src/services/api/prompts.ts b/web/src/services/api/prompts.ts index 6473051..eedf6c6 100644 --- a/web/src/services/api/prompts.ts +++ b/web/src/services/api/prompts.ts @@ -2,14 +2,17 @@ import localforage from "localforage"; import { runPromptSource, type RawPrompt } from "./prompt-source-runtime"; import { usePromptSourceStore } from "@/stores/use-prompt-source-store"; +import { usePromptStore, type PersonalPrompt } from "@/stores/use-prompt-store"; import type { PromptSource } from "./prompt-source-presets"; export type Prompt = RawPrompt & { + sourceId: string; category: string; githubUrl: string; }; export const ALL_PROMPTS_OPTION = "全部"; +export const PERSONAL_PROMPTS_CATEGORY = "我的提示词"; export type PromptListResponse = { items: Prompt[]; @@ -18,12 +21,34 @@ export type PromptListResponse = { 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 promptCacheStore = localforage.createInstance({ name: "infinite-canvas", storeName: "prompt_cache" }); - -type SourceCache = { items: Prompt[]; fetchedAt: number; signature: string }; - -const loadingSources = new Map>(); +const loadingSources = new Map>(); function enabledSources() { return usePromptSourceStore.getState().sources.filter((source) => source.enabled); @@ -33,41 +58,84 @@ function cacheKey(sourceId: string) { return `prompt-source:${sourceId}`; } -/** Cheap stable signature of a source so cached prompts invalidate when the script or name changes. */ function sourceSignature(source: PromptSource) { - const value = `${source.name}\n${source.githubUrl}\n${source.script}`; + const value = `${source.name}\n${source.url}\n${source.homepage}`; let hash = 0; - for (let i = 0; i < value.length; i += 1) { - hash = (hash * 31 + value.charCodeAt(i)) | 0; - } + for (let i = 0; i < value.length; i += 1) hash = (hash * 31 + value.charCodeAt(i)) | 0; return `${value.length}:${hash}`; } function withSourceMeta(source: PromptSource, items: RawPrompt[]): Prompt[] { - return items.map((item) => ({ ...item, category: source.name, githubUrl: source.githubUrl })); + 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 { - const items = await runPromptSource(source.script); - const prompts = withSourceMeta(source, items); - await promptCacheStore.setItem(cacheKey(source.id), { items: prompts, fetchedAt: Date.now(), signature: sourceSignature(source) }); - return prompts; +export function personalPromptToPrompt(item: PersonalPrompt): Prompt { + return { + ...item, + coverUrl: item.coverUrl || item.referenceImageUrls[0] || "", + sourceId: "personal", + category: PERSONAL_PROMPTS_CATEGORY, + githubUrl: "", + preview: "", + }; } -async function getSourcePrompts(source: PromptSource, force = false): Promise { - const signature = sourceSignature(source); - if (!force) { - const cached = await promptCacheStore.getItem(cacheKey(source.id)); - if (cached?.items?.length && cached.signature === signature && Date.now() - cached.fetchedAt < cacheTtlMs) return cached.items; +async function readSourceCache(sourceId: string) { + return promptCacheStore.getItem(cacheKey(sourceId)); +} + +async function refreshSourceRecord(source: PromptSource): Promise { + 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); return loading; } -/** Aggregate prompts across all enabled sources; a failing source is skipped so others still load. */ -async function getAllPrompts(): Promise { +async function getSourcePrompts(source: PromptSource): Promise { + 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 { const settled = await Promise.all( enabledSources().map(async (source) => { try { @@ -77,50 +145,76 @@ async function getAllPrompts(): Promise { } }), ); - 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 } = {}) { - const items = await getAllPrompts(); +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(includePersonal); const normalizedKeyword = keyword.trim().toLowerCase(); const normalizedPage = Math.max(1, page); const normalizedPageSize = Math.max(1, Math.min(100, pageSize)); const withoutTagFilter = filterPrompts(items, { keyword: normalizedKeyword, category, tags: [] }); const filtered = filterPrompts(items, { keyword: normalizedKeyword, category, tags: tag }); + const categories = enabledSources().map((source) => source.name); + if (includePersonal && usePromptStore.getState().prompts.length) categories.unshift(PERSONAL_PROMPTS_CATEGORY); return { items: filtered.slice((normalizedPage - 1) * normalizedPageSize, normalizedPage * normalizedPageSize), tags: collectTags(withoutTagFilter), - categories: enabledSources().map((source) => source.name), + categories, 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, force = false): Promise { +export async function fetchSourcePrompts(sourceId: string): Promise { const source = usePromptSourceStore.getState().sources.find((item) => item.id === sourceId); 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 { - const items = await fetchSourcePrompts(sourceId, true); - return items.length; +export async function refreshSource(sourceId: string): Promise { + const source = usePromptSourceStore.getState().sources.find((item) => item.id === sourceId); + if (!source) throw new Error("提示词来源不存在"); + 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 { - const settled = await Promise.all( +export async function refreshAllSources(): Promise { + const results = await Promise.all(enabledSources().map(getOrStartRefresh)); + return summarizeRefresh(results); +} + +export async function refreshDueSources(maxAgeMs: number): Promise { + const sources = await Promise.all( enabledSources().map(async (source) => { - try { - return await getSourcePrompts(source, true); - } catch { - return []; - } + const cached = await readSourceCache(source.id); + const lastSuccess = cached?.lastSuccessAt ? new Date(cached.lastSuccessAt).getTime() : 0; + return !lastSuccess || Boolean(cached?.lastError) || Date.now() - lastSuccess >= maxAgeMs || cached?.signature !== sourceSignature(source) ? source : null; }), ); - 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> { + 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[] }) { @@ -128,7 +222,7 @@ function filterPrompts(items: Prompt[], options: { keyword: string; category: st if (isActiveOption(options.category) && item.category !== options.category) return false; if (options.tags.length && !options.tags.some((tag) => item.tags.includes(tag))) return false; if (!options.keyword) return true; - return [item.title, item.prompt, item.category, ...item.tags].join(" ").toLowerCase().includes(options.keyword); + 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) { - return value && value !== "全部" && value !== "all"; + return value && value !== ALL_PROMPTS_OPTION && value !== "all"; } export function formatPromptDate(value: string) { diff --git a/web/src/stores/use-prompt-source-store.ts b/web/src/stores/use-prompt-source-store.ts index fabc6d4..47825f6 100644 --- a/web/src/stores/use-prompt-source-store.ts +++ b/web/src/stores/use-prompt-source-store.ts @@ -8,7 +8,7 @@ export type PromptSourceSchedule = { 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 = { intervalMinutes: 30, @@ -38,13 +38,14 @@ export const usePromptSourceStore = create()( (set) => ({ sources: DEFAULT_PROMPT_SOURCES, schedule: defaultSchedule, - addSource: () => { - const source = createPromptSource(); - set((state) => ({ sources: [...state.sources, source] })); - return source; - }, - saveSource: (source) => set((state) => ({ sources: state.sources.map((item) => (item.id === source.id ? source : item)) })), - removeSource: (id) => set((state) => ({ sources: state.sources.filter((item) => item.id !== id) })), + addSource: () => createPromptSource(), + saveSource: (source) => + set((state) => ({ + sources: state.sources.some((item) => item.id === source.id) + ? state.sources.map((item) => (item.id === source.id && !item.builtIn ? createPromptSource(source) : item)) + : [...state.sources, createPromptSource(source)], + })), + 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)) })), updateSchedule: (key, value) => set((state) => ({ schedule: { ...state.schedule, [key]: value } })), }), @@ -53,12 +54,11 @@ export const usePromptSourceStore = create()( partialize: (state) => ({ sources: state.sources, schedule: state.schedule }), merge: (persisted, current) => { const persistedState = (persisted || {}) as Partial; - const sources = Array.isArray(persistedState.sources) && persistedState.sources.length ? persistedState.sources.map((item) => createPromptSource(item)) : DEFAULT_PROMPT_SOURCES; - return { - ...current, - sources, - schedule: { ...defaultSchedule, ...(persistedState.schedule || {}) }, - }; + const savedSources = Array.isArray(persistedState.sources) ? persistedState.sources : []; + const enabledById = new Map(savedSources.map((source) => [source.id, source.enabled])); + const builtIn = DEFAULT_PROMPT_SOURCES.map((source) => ({ ...source, enabled: enabledById.get(source.id) ?? source.enabled })); + const custom = savedSources.filter((source) => !source.builtIn).map((source) => createPromptSource(source)); + return { ...current, sources: [...builtIn, ...custom], schedule: { ...defaultSchedule, ...(persistedState.schedule || {}) } }; }, }, ), diff --git a/web/src/stores/use-prompt-store.ts b/web/src/stores/use-prompt-store.ts new file mode 100644 index 0000000..079be89 --- /dev/null +++ b/web/src/stores/use-prompt-store.ts @@ -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; + +type PromptStore = { + hydrated: boolean; + prompts: PersonalPrompt[]; + addPrompt: (prompt: PersonalPromptInput) => string; + updatePrompt: (id: string, prompt: PersonalPromptInput) => void; + removePrompt: (id: string) => void; +}; + +export const usePromptStore = create()( + 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 }), + }, + ), +);