feat(prompts): add atlascloud logo SVG and update prompt source handling

This commit is contained in:
HouYunFei
2026-07-25 11:55:00 +08:00
parent 49851f2b5f
commit 0179724467
13 changed files with 80 additions and 346 deletions
+6 -4
View File
@@ -2,14 +2,16 @@
## Unreleased
+ [新增] 提示词来源新增 Banana Prompt Quicker,并支持添加自定义标准 JSON 来源。
+ [新增] 提示词中心新增「我的提示词」,支持本地管理、从公共库保存,并可在画布提示词库中直接使用。
+ [新增] Agent 新增统一生成任务状态查询,支持查看画布、生图工作台和视频工作台任务进度
+ [调整] 内置提示词来源改为读取 Image Prompts 统一 JSON 数据,不再由画布端分别解析
## v0.10.0 - 2026-07-25
+ [新增] 提示词来源新增BananaPromptQuicker,并支持添加自定义标准 JSON 来源
+ [新增] Agent 新增统一状态查询,支持查看画布、生图工作台和视频工作台任务进度
+ [调整] 内置提示词来源改为读取ImagePrompts 统一JSON 数据,不再由画布端分别解析。
+ [调整] 提示词来源配置保留卡片式交互,并展示来源数量、同步状态和上次成功时间。
+ [优化] 提示词来源支持独立缓存和更新,更新失败时保留上一次成功内容。
+ [优化] Agent 对话区分用户与 AI 消息,并优化会话切换和回到最新消息的交互。
+ [优化] 画布生成完成后保留提示词输入,并改善提示词库和资产面板的交互性能。
+ [优化] 提示词中心标题居中、搜索输入增加防抖,并采用左侧筛选、右侧内容的双栏布局。
+ [修复] 本地 Agent 完善多标签页请求隔离、结果归属、焦点回退和 Codex 会话状态同步。
+ [修复] 本地 Agent 上传的图片附件可正确创建画布图片节点并连接生成流程。
+ [修复] 画布提示词库支持跨来源搜索,插入节点时保留提示词标题。
+13
View File
@@ -29,6 +29,19 @@
>
> 如果你需要稳定维护自己的分支,建议自行 fork 后独立开发。二次开发与 PR 请保留原作者信息和前端页面标识。
## 赞助商
<table>
<tr>
<td width="190" align="center">
<a href="https://www.atlascloud.ai/zh?utm_source=github&amp;utm_medium=link&amp;utm_campaign=infinite-canvas"><img src="assets/atlascloud.svg" width="163" alt="Atlas Cloud"></a>
</td>
<td>
<a href="https://www.atlascloud.ai/zh?utm_source=github&amp;utm_medium=link&amp;utm_campaign=infinite-canvas">Atlas Cloud</a> is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out <a href="https://www.atlascloud.ai/console/coding-plan">Atlas Cloud's new coding plan promotion</a> for more budget-friendly API access.
</td>
</tr>
</table>
## 核心功能
- 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

+2 -2
View File
@@ -5,10 +5,10 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
- 提示词中心布局:页面标题及提示词总数应居中;连续输入搜索文字时应在停止输入约 300ms 后再查询;桌面端分类与标签应在左侧独立滚动,右侧搜索框下直接展示提示词卡片;标签数量较多时不能继续向下挤压提示词,窄屏下应恢复上下排列且内容不溢出;不再显示「我的提示词」Tab,收藏提示词应直接加入我的资产。
- 提示词来源: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 输出仅显示在事件所属线程,运行中不能新建、恢复、删除或再次发送任务。
+15 -55
View File
@@ -9,12 +9,11 @@ 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, personalPromptToPrompt, type Prompt } from "@/services/api/prompts";
import { fetchSourcePrompts, 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";
@@ -444,10 +443,9 @@ 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<Record<string, boolean>>({ personal: true });
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [detail, setDetail] = useState<Prompt | null>(null);
const copyPrompt = async (prompt: string) => {
@@ -466,32 +464,19 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
<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) => (
<PromptSourceGroup
key={source.id}
sourceId={source.id}
sourceName={source.name}
keyword={keyword}
open={!!expanded[source.id]}
theme={theme}
onToggle={() => setExpanded((prev) => ({ ...prev, [source.id]: !prev[source.id] }))}
onInsert={onInsert}
onView={setDetail}
/>
))}
</>
) : personalPrompts.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" className="pt-12" /> : null}
{enabledSources.length ? enabledSources.map((source) => (
<PromptSourceGroup
key={source.id}
sourceId={source.id}
sourceName={source.name}
keyword={keyword}
open={!!expanded[source.id]}
theme={theme}
onToggle={() => setExpanded((prev) => ({ ...prev, [source.id]: !prev[source.id] }))}
onInsert={onInsert}
onView={setDetail}
/>
)) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" className="pt-12" />}
</div>
</div>
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => void copyPrompt(prompt)} />
@@ -499,31 +484,6 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
);
});
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({
sourceId,
sourceName,
@@ -45,7 +45,7 @@ export function ConfigPromptSources() {
const handleDelete = (source: PromptSource) => {
modal.confirm({
title: `删除「${source.name}」?`,
content: "来源配置会被移除,已经保存到我的提示词的内容不受影响。",
content: "来源配置会被移除,已经加入我的资产的内容不受影响。",
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
@@ -12,7 +12,7 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
const [keyword, setKeyword] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
const { query, items, tags: promptTags, categories: promptCategories } = usePromptList({ keyword, tags: selectedTags, category: selectedCategory, enabled: open, includePersonal: true });
const { query, items, tags: promptTags, categories: promptCategories } = usePromptList({ keyword, tags: selectedTags, category: selectedCategory, enabled: open });
const toggleTag = (tag: string) => {
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
@@ -1,14 +1,19 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { ALL_PROMPTS_OPTION, fetchPrompts } from "@/services/api/prompts";
export const PROMPT_PAGE_SIZE = 20;
export function usePromptList({ keyword, tags, category, enabled = true, includePersonal = false }: { keyword: string; tags: string[]; category: string; enabled?: boolean; includePersonal?: boolean }) {
export function usePromptList({ keyword, tags, category, enabled = true }: { keyword: string; tags: string[]; category: string; enabled?: boolean }) {
const [debouncedKeyword, setDebouncedKeyword] = useState(keyword);
useEffect(() => {
const timer = setTimeout(() => setDebouncedKeyword(keyword), 300);
return () => clearTimeout(timer);
}, [keyword]);
const query = useInfiniteQuery({
queryKey: ["prompts", keyword, tags, category, includePersonal],
queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE, includePersonal }),
queryKey: ["prompts", debouncedKeyword, tags, category],
queryFn: ({ pageParam }) => fetchPrompts({ keyword: debouncedKeyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }),
initialPageParam: 1,
getNextPageParam: (lastPage, pages) => (pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined),
enabled,
@@ -1,108 +0,0 @@
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 { BookmarkPlus, Copy, FileText, FolderPlus } from "lucide-react";
import { 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, onSavePrompt }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void; onSavePrompt?: (prompt: Prompt) => void }) {
export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) {
return (
<>
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}>
@@ -35,11 +35,6 @@ export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset, onSav
</Button>
) : null}
{onSavePrompt ? (
<Button icon={<BookmarkPlus className="size-4" />} onClick={() => onSavePrompt(prompt)}>
</Button>
) : null}
</Space>
</div>
</div>
+26 -92
View File
@@ -1,38 +1,24 @@
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 { FolderPlus, Search } from "lucide-react";
import { type ReactNode, type UIEvent, useEffect, useState } from "react";
import { App, Button, Empty, Input, Spin, Tag } 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 { usePromptStore, type PersonalPrompt, type PersonalPromptInput } from "@/stores/use-prompt-store";
import { ALL_PROMPTS_OPTION, personalPromptToPrompt, type Prompt } from "@/services/api/prompts";
import { ALL_PROMPTS_OPTION, 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<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
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 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, 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]);
const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory });
useEffect(() => {
if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
@@ -48,104 +34,52 @@ export default function PromptsPage() {
message.success("已加入我的资产");
};
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<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 (
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
<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}>
<div className="mx-auto max-w-7xl pb-8">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold text-stone-950 dark:text-stone-100"></h1>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400"> {visibleCount} </p>
</div>
{activeTab === "personal" ? (
<Button type="primary" icon={<Plus className="size-4" />} onClick={openNewPrompt}>
</Button>
) : null}
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-4 py-6 [background-size:16px_16px] sm:px-6 lg:py-8 dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]" onScroll={handleListScroll}>
<div className="mx-auto max-w-7xl">
<div className="text-center">
<h1 className="text-2xl font-semibold text-stone-950 dark:text-stone-100"></h1>
<p className="mt-1 text-sm text-stone-500 dark:text-stone-400"> {totalPrompts} </p>
</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="mt-5 grid items-start gap-5 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-6">
<aside className="thin-scrollbar max-h-72 overflow-y-auto border-b border-stone-200 pb-5 lg:sticky lg:top-0 lg:max-h-[calc(100dvh-6rem)] lg:border-b-0 lg:border-r lg:pb-8 lg:pr-5 dark:border-stone-800">
<PromptFilter label="分类" options={promptCategoryOptions} selected={selectedCategory} onChange={setSelectedCategory} />
<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="flex flex-wrap gap-2">
<div className="mt-6">
<div className="mb-2 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500"></div>
<div className="flex flex-wrap gap-1.5">
{promptTags.map((tag) => {
const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
return <Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>{tag}</Tag.CheckableTag>;
})}
</div>
</div>
</div>
) : null}
</aside>
<section className="min-w-0">
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={titleKeyword} placeholder="搜索标题、内容或标签" onChange={(event) => setTitleKeyword(event.target.value)} />
{query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
{!query.isLoading ? <div className="mt-5"><PromptGrid items={promptItems} onOpen={setSelectedPrompt} renderActions={(item) => <Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}></Button>} onCopy={(item) => copyText(item.prompt, "提示词已复制")} emptyText="没有找到匹配的提示词" /></div> : null}
<div className="mt-6 text-center text-xs text-stone-500 dark:text-stone-400">{query.isFetchingNextPage ? "加载中..." : query.hasNextPage ? "继续向下滚动加载更多" : promptItems.length > 0 ? "已经到底了" : null}</div>
</section>
</div>
</div>
{activeTab === "library" && query.isLoading ? <div className="flex h-60 items-center justify-center"><Spin /></div> : null}
{activeTab === "library" && !query.isLoading ? (
<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="没有找到匹配的提示词" />
) : 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>
<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)} />
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} />
</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>;
return <div><div className="mb-2 text-xs font-semibold uppercase tracking-widest text-stone-400 dark:text-stone-500">{label}</div><div className="flex flex-wrap gap-1.5">{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>;
return <div><div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">{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 };
}
+4 -19
View File
@@ -2,7 +2,6 @@ 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 & {
@@ -12,7 +11,6 @@ export type Prompt = RawPrompt & {
};
export const ALL_PROMPTS_OPTION = "全部";
export const PERSONAL_PROMPTS_CATEGORY = "我的提示词";
export type PromptListResponse = {
items: Prompt[];
@@ -76,17 +74,6 @@ function withSourceMeta(source: PromptSource, items: RawPrompt[]): Prompt[] {
}));
}
export function personalPromptToPrompt(item: PersonalPrompt): Prompt {
return {
...item,
coverUrl: item.coverUrl || item.referenceImageUrls[0] || "",
sourceId: "personal",
category: PERSONAL_PROMPTS_CATEGORY,
githubUrl: "",
preview: "",
};
}
async function readSourceCache(sourceId: string) {
return promptCacheStore.getItem<SourceCache>(cacheKey(sourceId));
}
@@ -135,7 +122,7 @@ async function getSourcePrompts(source: PromptSource): Promise<Prompt[]> {
return (await readSourceCache(source.id))?.items || [];
}
async function getAllPrompts(includePersonal: boolean): Promise<Prompt[]> {
async function getAllPrompts(): Promise<Prompt[]> {
const settled = await Promise.all(
enabledSources().map(async (source) => {
try {
@@ -145,19 +132,17 @@ async function getAllPrompts(includePersonal: boolean): Promise<Prompt[]> {
}
}),
);
const personal = includePersonal ? usePromptStore.getState().prompts.map(personalPromptToPrompt) : [];
return [...personal, ...settled.flat()];
return settled.flat();
}
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);
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();
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),
-53
View File
@@ -1,53 +0,0 @@
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 }),
},
),
);