feat(prompts): unify sources and add personal library

This commit is contained in:
yu
2026-07-20 23:16:46 +08:00
parent c81bb8b651
commit 49851f2b5f
19 changed files with 736 additions and 539 deletions
+49 -12
View File
@@ -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<Record<string, boolean>>(() => (enabledSources[0] ? { [enabledSources[0].id]: true } : {}));
const [expanded, setExpanded] = useState<Record<string, boolean>>({ personal: true });
const [detail, setDetail] = useState<Prompt | null>(null);
const copyPrompt = async (prompt: string) => {
@@ -463,8 +465,18 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索提示词" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{enabledSources.length ? (
<div className="space-y-1">
<div className="space-y-1">
<PersonalPromptGroup
items={personalPrompts.map(personalPromptToPrompt)}
keyword={keyword}
open={!!expanded.personal}
theme={theme}
onToggle={() => setExpanded((prev) => ({ ...prev, personal: !prev.personal }))}
onInsert={onInsert}
onView={setDetail}
/>
{enabledSources.length ? (
<>
{enabledSources.map((source) => (
<PromptSourceGroup
key={source.id}
@@ -478,16 +490,40 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
onView={setDetail}
/>
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词来源" className="pt-16" />
)}
</>
) : personalPrompts.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" className="pt-12" /> : null}
</div>
</div>
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => void copyPrompt(prompt)} />
</div>
);
});
function PersonalPromptGroup({ items, keyword, open, theme, onToggle, onInsert, onView }: { items: Prompt[]; keyword: string; open: boolean; theme: CanvasTheme; onToggle: () => void; onInsert: (payload: InsertAssetPayload) => void; onView: (prompt: Prompt) => void }) {
const showResults = open || !!keyword.trim();
const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase();
return query ? items.filter((item) => [item.title, item.prompt, item.description, ...item.tags].join(" ").toLowerCase().includes(query)) : items;
}, [items, keyword]);
return (
<div>
<button type="button" onClick={onToggle} className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs font-semibold opacity-75 transition hover:opacity-100">
<ChevronRight className={cn("size-3.5 transition-transform", showResults && "rotate-90")} />
<BookOpen className="size-3.5" />
<span className="min-w-0 flex-1 truncate"></span>
<span className="opacity-50">{filtered.length}</span>
</button>
{showResults ? (
<div className="space-y-1.5 px-1 pb-2 pt-1">
{filtered.map((item) => <PromptRow key={item.id} item={item} theme={theme} onInsert={() => onInsert({ kind: "text", content: item.prompt, title: item.title })} onView={() => onView(item)} />)}
{!filtered.length ? <div className="py-4 text-center text-xs opacity-40">{keyword.trim() ? "无匹配提示词" : "还没有保存提示词"}</div> : null}
</div>
) : null}
</div>
);
}
function PromptSourceGroup({
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 (
<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", open && "rotate-90")} />
<ChevronRight className={cn("size-3.5 transition-transform", showResults && "rotate-90")} />
<BookOpen className="size-3.5" />
<span className="min-w-0 flex-1 truncate">{sourceName}</span>
{open && query.isSuccess ? <span className="opacity-50">{filtered.length}</span> : null}
{showResults && query.isSuccess ? <span className="opacity-50">{filtered.length}</span> : null}
</button>
{open ? (
{showResults ? (
<div className="px-1 pb-2 pt-1">
{query.isLoading ? (
<div className="flex justify-center py-6">
@@ -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<PromptSource | null>(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 (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-stone-500"></div>
<Button type="primary" icon={<Plus className="size-4" />} onClick={handleAdd}>
<div className="mb-4 flex flex-wrap items-center justify-end gap-3">
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => setEditingSource(addSource())}>
</Button>
</div>
<div className="space-y-2">
{sources.map((source) => (
<div key={source.id} className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
<div className="flex min-w-0 items-center gap-3">
<Switch size="small" checked={source.enabled} onChange={(checked) => toggleSource(source.id, checked)} />
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{source.name || "未命名来源"}</div>
<div className="mt-1 truncate text-xs text-stone-500">{source.githubUrl || "无 GitHub 地址"}</div>
{sources.map((source) => {
const status = statusQuery.data?.[source.id];
return (
<div key={source.id} className="flex flex-wrap items-center gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
<Switch size="small" checked={source.enabled} onChange={(checked) => { toggleSource(source.id, checked); void invalidatePrompts(); }} />
<div className="min-w-[220px] flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold">{source.name}</span>
{source.builtIn ? <Tag className="m-0 shrink-0 text-[10px]"></Tag> : null}
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<a className="max-w-full truncate hover:text-stone-800 hover:underline dark:hover:text-stone-200" href={source.homepage || source.url} target="_blank" rel="noreferrer">
{source.homepage || source.url}
</a>
<span className="tabular-nums">{status?.count ?? 0} </span>
{status?.lastError ? <Tag color="error" className="m-0 text-[10px]" title={status.lastError}></Tag> : status?.lastSuccessAt ? <Tag color="success" className="m-0 text-[10px]"></Tag> : <Tag className="m-0 text-[10px]"></Tag>}
<span>{status?.lastSuccessAt ? `上次成功 ${formatTime(status.lastSuccessAt)}` : "尚未拉取"}</span>
</div>
</div>
<div className="ml-auto flex flex-wrap justify-end gap-2">
<Button size="small" icon={<Eye className="size-3.5" />} onClick={() => setViewingId(source.id)}>
</Button>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={refreshingId === source.id} onClick={() => void handleRefreshOne(source)}>
</Button>
{!source.builtIn ? <Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingSource(source)}></Button> : null}
{!source.builtIn ? <Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(source)}></Button> : null}
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button size="small" icon={<Eye className="size-3.5" />} onClick={() => setViewingId(source.id)}>
</Button>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={refreshingId === source.id} onClick={() => void handleRefreshOne(source)}>
</Button>
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingId(source.id)}>
</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(source)} />
</div>
</div>
))}
);
})}
</div>
<section className="mt-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<section className="mt-5 rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<div className="mb-3 text-sm font-semibold"></div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
@@ -126,12 +143,13 @@ export function ConfigPromptSources() {
<div className="mt-2 text-xs text-stone-400"></div>
</section>
<PromptSourceEditorDrawer open={Boolean(editingSource)} source={editingSource} onSave={handleSave} onClose={() => setEditingId("")} />
<PromptSourceEditorDrawer open={Boolean(editingSource)} source={editingSource} onSave={handleSave} onClose={() => setEditingSource(null)} />
<PromptSourceContentModal source={viewingSource} onClose={() => setViewingId("")} />
</div>
);
}
function formatTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
@@ -55,7 +55,7 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
<div className="mt-0.5 text-xs font-normal text-stone-500"> {items.length} </div>
</div>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void load(true)}>
</Button>
</div>
}
@@ -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<PromptSource | null>(source);
useEffect(() => {
@@ -22,15 +16,20 @@ export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { op
const patch = (value: Partial<PromptSource>) => setDraft((current) => (current ? { ...current, ...value } : current));
const 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 (
<Drawer
open={open}
width={880}
title="编辑提示词来源"
width={560}
title={source?.name === "新来源" ? "新增提示词来源" : "编辑提示词来源"}
onClose={onClose}
styles={{ body: { paddingTop: 16 } }}
extra={
@@ -42,67 +41,46 @@ export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { op
</Space>
}
>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-5">
<label className="block">
<span className="mb-1 block text-sm font-medium"></span>
<span className="mb-1.5 block text-sm font-medium"></span>
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} placeholder="用于分类展示" />
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium">GitHub </span>
<Input value={draft.githubUrl} onChange={(event) => patch({ githubUrl: event.target.value })} placeholder="https://github.com/owner/repo" />
<span className="mb-1.5 block text-sm font-medium">JSON URL</span>
<Input value={draft.url} onChange={(event) => patch({ url: event.target.value })} placeholder="https://example.com/prompts.json" />
</label>
</div>
<div className="mt-6 mb-2 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold"></div>
<div className="mt-0.5 text-xs text-stone-500">使 return title prompt</div>
<label className="block">
<span className="mb-1.5 block text-sm font-medium"></span>
<Input value={draft.homepage} onChange={(event) => patch({ homepage: event.target.value })} placeholder="https://example.com" />
</label>
<div className="flex items-center justify-between border-y border-stone-200 py-3 dark:border-stone-800">
<span className="text-sm font-medium"></span>
<Switch checked={draft.enabled} onChange={(enabled) => patch({ enabled })} />
</div>
<Button size="small" onClick={() => patch({ script: PROMPT_SOURCE_TEMPLATE })}>
</Button>
</div>
<div className="flex h-[56vh] min-h-[420px] overflow-hidden rounded-lg border border-stone-200 dark:border-stone-800">
<aside className="flex w-[300px] shrink-0 flex-col overflow-y-auto border-r border-stone-200 bg-stone-50/80 dark:border-stone-800 dark:bg-stone-900/40">
<div className="px-4 py-3">
<div className="mb-2.5 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wide text-stone-400"></span>
<span className="text-[10px] text-stone-400"></span>
</div>
<div className="space-y-1.5">
{PROMPT_SOURCE_VARIABLES.map((variable) => (
<button
key={variable.name}
type="button"
onClick={() => patch({ script: draft.script ? `${draft.script}\n${variable.name}` : variable.name })}
className="group block w-full rounded-lg border border-transparent px-2.5 py-2 text-left transition-colors hover:border-stone-200 hover:bg-white dark:hover:border-stone-700 dark:hover:bg-stone-800/60"
>
<div className="flex flex-wrap items-baseline gap-1.5">
<code className="rounded bg-stone-200/80 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-stone-800 group-hover:bg-blue-100 group-hover:text-blue-700 dark:bg-stone-800 dark:text-stone-100 dark:group-hover:bg-blue-950 dark:group-hover:text-blue-300">
{variable.name}
</code>
<span className="font-mono text-[10px] text-stone-400">{variable.type}</span>
</div>
<div className="mt-1 text-xs leading-5 text-stone-500 dark:text-stone-400">{variable.desc}</div>
</button>
))}
</div>
</div>
</aside>
<div className="min-w-0 flex-1 overflow-hidden bg-white dark:bg-stone-950">
<CodeMirror
value={draft.script}
onChange={(value) => patch({ script: value })}
height="100%"
theme={isDarkMode() ? "dark" : "light"}
extensions={[javascript()]}
placeholder={"// return 一个提示词数组;点击右上角「插入模板」查看示例。"}
style={{ height: "100%", fontSize: 13 }}
className="h-full [&_.cm-editor]:h-full [&_.cm-gutters]:border-none [&_.cm-scroller]:overflow-auto"
/>
<div>
<div className="mb-2 text-sm font-medium">JSON </div>
<pre className="overflow-x-auto rounded-md bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{`[
{
"id": "product-photo-1",
"title": "白底商品图",
"prompt": "生成专业白底商品摄影图",
"description": "",
"coverUrl": "",
"referenceImageUrls": [],
"tags": ["商品", "摄影"]
}
]`}</pre>
</div>
</div>
</Drawer>
);
}
function isHttpUrl(value: string) {
try {
return ["http:", "https:"].includes(new URL(value).protocol);
} catch {
return false;
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { Copy } from "lucide-react";
import { Copy, FileText } from "lucide-react";
import type { ReactNode } from "react";
import { Button, Card, Tag } from "antd";
@@ -28,7 +28,7 @@ export function PromptCard({
styles={{ body: { padding: 0 } }}
cover={
<button type="button" className="block w-full text-left" onClick={onOpen}>
<img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" />
{item.coverUrl ? <img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" loading="lazy" /> : <span className="grid aspect-[4/3] w-full place-items-center bg-stone-100 text-stone-400 dark:bg-stone-900 dark:text-stone-600"><FileText className="size-8" /></span>}
</button>
}
>
@@ -38,7 +38,7 @@ export function PromptCard({
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2>
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt)}</span>
</div>
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.prompt}</p>
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.description || item.prompt}</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{item.tags.map((tag) => (
<Tag key={tag} className="m-0 text-[11px]">
@@ -12,7 +12,7 @@ export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boo
const [keyword, setKeyword] = useState("");
const [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 });
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]));
@@ -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,